From 24bb31c109332ae982b7091ffcd5183442ce6f6f Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 10 May 2026 20:01:21 +0900 Subject: refactor(vcs): move VCS drivers under repository module Mirror Composer's structure where VCS drivers live under Repository/Vcs/. Rename VcsDriver trait to VcsDriverInterface and BitbucketDriver to GitBitbucketDriver to match Composer naming, and add stubs for FossilDriver, PerforceDriver, and the VcsDriver base. --- crates/mozart-core/src/repository.rs | 1 + crates/mozart-core/src/repository/vcs.rs | 284 +++++++++++++++++++ .../src/repository/vcs/forgejo_driver.rs | 285 +++++++++++++++++++ .../src/repository/vcs/fossil_driver.rs | 1 + .../src/repository/vcs/git_bitbucket_driver.rs | 276 ++++++++++++++++++ .../mozart-core/src/repository/vcs/git_driver.rs | 275 ++++++++++++++++++ .../src/repository/vcs/github_driver.rs | 314 ++++++++++++++++++++ .../src/repository/vcs/gitlab_driver.rs | 300 ++++++++++++++++++++ crates/mozart-core/src/repository/vcs/hg_driver.rs | 203 +++++++++++++ .../src/repository/vcs/perforce_driver.rs | 1 + .../mozart-core/src/repository/vcs/svn_driver.rs | 214 ++++++++++++++ .../mozart-core/src/repository/vcs/vcs_driver.rs | 1 + .../src/repository/vcs/vcs_driver_interface.rs | 43 +++ crates/mozart-core/src/repository/vcs_bridge.rs | 6 +- crates/mozart-core/src/vcs.rs | 1 - crates/mozart-core/src/vcs/driver/bitbucket.rs | 277 ------------------ crates/mozart-core/src/vcs/driver/forgejo.rs | 285 ------------------- crates/mozart-core/src/vcs/driver/git.rs | 275 ------------------ crates/mozart-core/src/vcs/driver/github.rs | 315 --------------------- crates/mozart-core/src/vcs/driver/gitlab.rs | 301 -------------------- crates/mozart-core/src/vcs/driver/hg.rs | 202 ------------- crates/mozart-core/src/vcs/driver/mod.rs | 309 -------------------- crates/mozart-core/src/vcs/driver/svn.rs | 214 -------------- crates/mozart-core/src/vcs/repository.rs | 2 +- 24 files changed, 2202 insertions(+), 2183 deletions(-) create mode 100644 crates/mozart-core/src/repository/vcs.rs create mode 100644 crates/mozart-core/src/repository/vcs/forgejo_driver.rs create mode 100644 crates/mozart-core/src/repository/vcs/fossil_driver.rs create mode 100644 crates/mozart-core/src/repository/vcs/git_bitbucket_driver.rs create mode 100644 crates/mozart-core/src/repository/vcs/git_driver.rs create mode 100644 crates/mozart-core/src/repository/vcs/github_driver.rs create mode 100644 crates/mozart-core/src/repository/vcs/gitlab_driver.rs create mode 100644 crates/mozart-core/src/repository/vcs/hg_driver.rs create mode 100644 crates/mozart-core/src/repository/vcs/perforce_driver.rs create mode 100644 crates/mozart-core/src/repository/vcs/svn_driver.rs create mode 100644 crates/mozart-core/src/repository/vcs/vcs_driver.rs create mode 100644 crates/mozart-core/src/repository/vcs/vcs_driver_interface.rs delete mode 100644 crates/mozart-core/src/vcs/driver/bitbucket.rs delete mode 100644 crates/mozart-core/src/vcs/driver/forgejo.rs delete mode 100644 crates/mozart-core/src/vcs/driver/git.rs delete mode 100644 crates/mozart-core/src/vcs/driver/github.rs delete mode 100644 crates/mozart-core/src/vcs/driver/gitlab.rs delete mode 100644 crates/mozart-core/src/vcs/driver/hg.rs delete mode 100644 crates/mozart-core/src/vcs/driver/mod.rs delete mode 100644 crates/mozart-core/src/vcs/driver/svn.rs (limited to 'crates/mozart-core/src') diff --git a/crates/mozart-core/src/repository.rs b/crates/mozart-core/src/repository.rs index ba96729..ce6de85 100644 --- a/crates/mozart-core/src/repository.rs +++ b/crates/mozart-core/src/repository.rs @@ -14,6 +14,7 @@ pub mod path_repository; pub mod repository; pub mod repository_filter; pub mod resolver; +pub mod vcs; pub mod vcs_bridge; pub mod version; pub mod version_selector; diff --git a/crates/mozart-core/src/repository/vcs.rs b/crates/mozart-core/src/repository/vcs.rs new file mode 100644 index 0000000..88ed702 --- /dev/null +++ b/crates/mozart-core/src/repository/vcs.rs @@ -0,0 +1,284 @@ +mod forgejo_driver; +mod fossil_driver; +mod git_bitbucket_driver; +mod git_driver; +mod github_driver; +mod gitlab_driver; +mod hg_driver; +mod perforce_driver; +mod svn_driver; +mod vcs_driver; +mod vcs_driver_interface; + +pub use forgejo_driver::*; +pub use fossil_driver::*; +pub use git_bitbucket_driver::*; +pub use git_driver::*; +pub use github_driver::*; +pub use gitlab_driver::*; +pub use hg_driver::*; +pub use perforce_driver::*; +pub use svn_driver::*; +pub use vcs_driver::*; +pub use vcs_driver_interface::*; + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +/// Reference to a source distribution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceReference { + #[serde(rename = "type")] + pub source_type: String, + pub url: String, + pub reference: String, +} + +/// Reference to a dist (archive) distribution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DistReference { + #[serde(rename = "type")] + pub dist_type: String, + pub url: String, + pub reference: String, + pub shasum: Option, +} + +/// Configuration passed to VCS drivers. +#[derive(Debug, Clone)] +pub struct DriverConfig { + /// Composer's `cache-vcs-dir`: root for VCS mirrors, one + /// subdirectory per sanitized repository URL. + pub cache_vcs_dir: PathBuf, + /// GitHub OAuth token (from `GITHUB_TOKEN` or config). + pub github_token: Option, + /// GitLab OAuth token. + pub gitlab_token: Option, + /// Bitbucket OAuth consumer key/secret. + pub bitbucket_oauth: Option<(String, String)>, + /// Forgejo token. + pub forgejo_token: Option, + /// Custom GitLab domains (for self-hosted). + pub gitlab_domains: Vec, + /// Custom Forgejo domains (for self-hosted). + pub forgejo_domains: Vec, +} + +impl Default for DriverConfig { + fn default() -> Self { + Self { + cache_vcs_dir: default_cache_vcs_dir(), + github_token: None, + gitlab_token: None, + bitbucket_oauth: None, + forgejo_token: None, + gitlab_domains: vec!["gitlab.com".to_string()], + forgejo_domains: vec!["codeberg.org".to_string()], + } + } +} + +/// Resolve the default `cache-vcs-dir`, honoring Composer's env vars. +/// +/// Priority: `COMPOSER_CACHE_VCS_DIR` → `COMPOSER_CACHE_DIR/vcs` → +/// `XDG_CACHE_HOME/mozart/vcs` → `$HOME/.cache/mozart/vcs`. +fn default_cache_vcs_dir() -> PathBuf { + if let Ok(p) = std::env::var("COMPOSER_CACHE_VCS_DIR") { + return PathBuf::from(p); + } + let base = if let Ok(p) = std::env::var("COMPOSER_CACHE_DIR") { + PathBuf::from(p) + } else if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { + PathBuf::from(xdg).join("mozart") + } else if let Ok(home) = std::env::var("HOME") { + PathBuf::from(home).join(".cache").join("mozart") + } else { + PathBuf::from("/tmp").join("mozart") + }; + base.join("vcs") +} + +/// Type of VCS driver. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DriverType { + GitHub, + GitLab, + Bitbucket, + Forgejo, + Git, + Svn, + Hg, +} + +/// Enum-dispatched VCS driver. +/// +/// Wraps all concrete driver types to allow static dispatch with async trait methods. +pub enum AnyVcsDriver { + GitHub(GitHubDriver), + GitLab(GitLabDriver), + Bitbucket(GitBitbucketDriver), + Forgejo(ForgejoDriver), + Git(GitDriver), + Svn(SvnDriver), + Hg(HgDriver), +} + +macro_rules! dispatch { + ($self:expr, $method:ident $(, $arg:expr)*) => { + match $self { + AnyVcsDriver::GitHub(d) => d.$method($($arg),*), + AnyVcsDriver::GitLab(d) => d.$method($($arg),*), + AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*), + AnyVcsDriver::Forgejo(d) => d.$method($($arg),*), + AnyVcsDriver::Git(d) => d.$method($($arg),*), + AnyVcsDriver::Svn(d) => d.$method($($arg),*), + AnyVcsDriver::Hg(d) => d.$method($($arg),*), + } + }; +} + +macro_rules! dispatch_async { + ($self:expr, $method:ident $(, $arg:expr)*) => { + match $self { + AnyVcsDriver::GitHub(d) => d.$method($($arg),*).await, + AnyVcsDriver::GitLab(d) => d.$method($($arg),*).await, + AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*).await, + AnyVcsDriver::Forgejo(d) => d.$method($($arg),*).await, + AnyVcsDriver::Git(d) => d.$method($($arg),*).await, + AnyVcsDriver::Svn(d) => d.$method($($arg),*).await, + AnyVcsDriver::Hg(d) => d.$method($($arg),*).await, + } + }; +} + +impl AnyVcsDriver { + pub async fn initialize(&mut self) -> Result<()> { + dispatch_async!(self, initialize) + } + + pub fn root_identifier(&self) -> &str { + dispatch!(self, root_identifier) + } + + pub async fn branches(&mut self) -> Result<&BTreeMap> { + dispatch_async!(self, branches) + } + + pub async fn tags(&mut self) -> Result<&BTreeMap> { + dispatch_async!(self, tags) + } + + pub async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + dispatch_async!(self, composer_information, identifier) + } + + pub async fn file_content(&self, file: &str, identifier: &str) -> Result> { + dispatch_async!(self, file_content, file, identifier) + } + + pub async fn change_date(&self, identifier: &str) -> Result> { + dispatch_async!(self, change_date, identifier) + } + + pub async fn dist(&self, identifier: &str) -> Result> { + dispatch_async!(self, dist, identifier) + } + + pub fn source(&self, identifier: &str) -> SourceReference { + dispatch!(self, source, identifier) + } + + pub fn url(&self) -> &str { + dispatch!(self, url) + } + + pub async fn cleanup(&mut self) -> Result<()> { + dispatch_async!(self, cleanup) + } +} + +/// Detect which driver type should handle a given URL. +/// +/// Priority order matches Composer: +/// 1. GitHub → 2. GitLab → 3. Bitbucket → 4. Forgejo → 5. Git → 6. Hg → 7. SVN +pub fn detect_driver( + url: &str, + forced_type: Option<&str>, + config: &DriverConfig, +) -> Option { + if let Some(t) = forced_type { + return match t { + "github" => Some(DriverType::GitHub), + "gitlab" => Some(DriverType::GitLab), + "bitbucket" => Some(DriverType::Bitbucket), + "forgejo" => Some(DriverType::Forgejo), + "git" => Some(DriverType::Git), + "svn" => Some(DriverType::Svn), + "hg" | "mercurial" => Some(DriverType::Hg), + _ => None, + }; + } + + let url_lower = url.to_lowercase(); + + // GitHub + if GitHubDriver::supports(url) { + return Some(DriverType::GitHub); + } + + // GitLab + if GitLabDriver::supports(url, &config.gitlab_domains) { + return Some(DriverType::GitLab); + } + + // Bitbucket + if GitBitbucketDriver::supports(url) { + return Some(DriverType::Bitbucket); + } + + // Forgejo + if ForgejoDriver::supports(url, &config.forgejo_domains) { + return Some(DriverType::Forgejo); + } + + // Git + if GitDriver::supports(url) { + return Some(DriverType::Git); + } + + // Hg + if HgDriver::supports(url) { + return Some(DriverType::Hg); + } + + // SVN + if url_lower.contains("svn") || SvnDriver::supports(url) { + return Some(DriverType::Svn); + } + + // Default to git for generic URLs + if url.starts_with("http://") || url.starts_with("https://") { + return Some(DriverType::Git); + } + + None +} + +/// Create a driver instance for the given URL and type. +pub fn create_driver(url: &str, driver_type: DriverType, config: DriverConfig) -> AnyVcsDriver { + match driver_type { + DriverType::GitHub => AnyVcsDriver::GitHub(GitHubDriver::new(url, config)), + DriverType::GitLab => AnyVcsDriver::GitLab(GitLabDriver::new(url, config)), + DriverType::Bitbucket => AnyVcsDriver::Bitbucket(GitBitbucketDriver::new(url, config)), + DriverType::Forgejo => AnyVcsDriver::Forgejo(ForgejoDriver::new(url, config)), + DriverType::Git => AnyVcsDriver::Git(GitDriver::new(url, config)), + DriverType::Svn => AnyVcsDriver::Svn(SvnDriver::new(url, config)), + DriverType::Hg => AnyVcsDriver::Hg(HgDriver::new(url, config)), + } +} diff --git a/crates/mozart-core/src/repository/vcs/forgejo_driver.rs b/crates/mozart-core/src/repository/vcs/forgejo_driver.rs new file mode 100644 index 0000000..699959b --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/forgejo_driver.rs @@ -0,0 +1,285 @@ +use crate::repository::vcs::{ + DistReference, DriverConfig, GitDriver, SourceReference, VcsDriverInterface, + base64_decode_content, +}; +use anyhow::{Result, bail}; +use indexmap::IndexMap; +use regex::Regex; +use reqwest::Client; +use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; +use std::collections::BTreeMap; + +/// Forgejo/Gitea VCS driver using the REST API v1. +/// +/// Supports self-hosted instances (Codeberg, etc.). +pub struct ForgejoDriver { + owner: String, + repo: String, + host: String, + scheme: String, + url: String, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + git_driver: Option>, + http_client: Client, + config: DriverConfig, + api_failed: bool, +} + +impl ForgejoDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default(); + Self { + owner, + repo, + host, + scheme, + url: url.to_string(), + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + git_driver: None, + http_client: crate::http::default_client(), + config, + api_failed: false, + } + } + + pub fn supports(url: &str, forgejo_domains: &[String]) -> bool { + let url_lower = url.to_lowercase(); + for domain in forgejo_domains { + if url_lower.contains(domain) { + return true; + } + } + false + } + + fn parse_url(url: &str) -> Option<(String, String, String, String)> { + let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") + .ok()?; + let caps = re.captures(url)?; + Some(( + caps[2].to_string(), + caps[1].to_string(), + caps[3].to_string(), + caps[4].to_string(), + )) + } + + fn api_url(&self, path: &str) -> String { + format!( + "{}://{}/api/v1/repos/{}/{}{}", + self.scheme, self.host, self.owner, self.repo, path, + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result { + let url = self.api_url(path); + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/json"); + if let Some(token) = &self.config.forgejo_token { + req = req.header(AUTHORIZATION, format!("token {token}")); + } + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "Forgejo API response"); + if !response.status().is_success() { + bail!( + "Forgejo API request to {} failed: {}", + url, + response.status() + ); + } + Ok(response.json().await?) + } + + #[tracing::instrument(skip(self))] + async fn api_get_paginated(&self, path: &str) -> Result> { + let mut items = Vec::new(); + let mut page = 1; + loop { + let sep = if path.contains('?') { "&" } else { "?" }; + let paged_path = format!("{path}{sep}limit=50&page={page}"); + let data = self.api_get(&paged_path).await?; + let batch: Vec = match data { + serde_json::Value::Array(arr) => arr, + _ => break, + }; + if batch.is_empty() { + break; + } + items.extend(batch); + page += 1; + if page > 20 { + break; + } + } + Ok(items) + } + + async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { + if self.git_driver.is_none() { + let git_url = format!( + "{}://{}/{}/{}.git", + self.scheme, self.host, self.owner, self.repo + ); + let mut driver = GitDriver::new(&git_url, self.config.clone()); + driver.initialize().await?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriverInterface for ForgejoDriver { + async fn initialize(&mut self) -> Result<()> { + match self.api_get("").await { + Ok(data) => { + let default_branch = data["default_branch"] + .as_str() + .unwrap_or("main") + .to_string(); + self.root_identifier = Some(default_branch); + } + Err(_) => { + self.api_failed = true; + let driver = self.use_git_fallback().await?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let branches = driver.branches().await?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/branches").await?; + let mut branches = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["commit"]["id"].as_str()) + { + branches.insert(name.to_string(), sha.to_string()); + } + } + self.branches = Some(branches); + } + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let tags = driver.tags().await?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/tags").await?; + let mut tags = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = ( + item["name"].as_str(), + item["id"].as_str().or(item["commit"]["sha"].as_str()), + ) { + tags.insert(name.to_string(), sha.to_string()); + } + } + self.tags = Some(tags); + } + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier).await?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + let path = format!("/contents/{}?ref={}", file, identifier); + match self.api_get(&path).await { + Ok(data) => { + if let Some(content) = data["content"].as_str() { + // Forgejo returns base64-encoded content + let decoded = base64_decode_content(content)?; + Ok(Some(decoded)) + } else { + Ok(None) + } + } + Err(_) => Ok(None), + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + match self.api_get(&format!("/git/commits/{identifier}")).await { + Ok(data) => Ok(data["created"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + async fn dist(&self, identifier: &str) -> Result> { + Ok(Some(DistReference { + dist_type: "zip".to_string(), + url: format!( + "{}://{}/{}/{}/archive/{}.zip", + self.scheme, self.host, self.owner, self.repo, identifier, + ), + reference: identifier.to_string(), + shasum: None, + })) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "git".to_string(), + url: format!( + "{}://{}/{}/{}.git", + self.scheme, self.host, self.owner, self.repo + ), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup().await?; + } + Ok(()) + } +} diff --git a/crates/mozart-core/src/repository/vcs/fossil_driver.rs b/crates/mozart-core/src/repository/vcs/fossil_driver.rs new file mode 100644 index 0000000..8d45a43 --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/fossil_driver.rs @@ -0,0 +1 @@ +pub struct FossilDriver; diff --git a/crates/mozart-core/src/repository/vcs/git_bitbucket_driver.rs b/crates/mozart-core/src/repository/vcs/git_bitbucket_driver.rs new file mode 100644 index 0000000..0ec1dfd --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/git_bitbucket_driver.rs @@ -0,0 +1,276 @@ +use crate::repository::vcs::{ + DistReference, DriverConfig, GitDriver, SourceReference, VcsDriverInterface, +}; +use anyhow::{Result, bail}; +use indexmap::IndexMap; +use regex::Regex; +use reqwest::Client; +use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; +use std::collections::BTreeMap; + +/// Bitbucket VCS driver using the REST API 2.0. +pub struct GitBitbucketDriver { + owner: String, + repo: String, + url: String, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + git_driver: Option>, + http_client: Client, + config: DriverConfig, + api_failed: bool, + vcs_type: String, // "git" or "hg" +} + +impl GitBitbucketDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let (owner, repo) = Self::parse_url(url).unwrap_or_default(); + Self { + owner, + repo, + url: url.to_string(), + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + git_driver: None, + http_client: crate::http::default_client(), + config, + api_failed: false, + vcs_type: "git".to_string(), + } + } + + pub fn supports(url: &str) -> bool { + let url_lower = url.to_lowercase(); + url_lower.contains("bitbucket.org") + } + + fn parse_url(url: &str) -> Option<(String, String)> { + let re = + Regex::new(r"bitbucket\.org[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; + let caps = re.captures(url)?; + Some((caps[1].to_string(), caps[2].to_string())) + } + + fn api_url(&self, path: &str) -> String { + format!( + "https://api.bitbucket.org/2.0/repositories/{}/{}{}", + self.owner, self.repo, path, + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result { + let url = self.api_url(path); + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/json"); + + if let Some((key, secret)) = &self.config.bitbucket_oauth { + let credentials = format!("{key}:{secret}"); + req = req.header(AUTHORIZATION, format!("Basic {credentials}")); + } + + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "Bitbucket API response"); + if !response.status().is_success() { + bail!( + "Bitbucket API request to {} failed: {}", + url, + response.status() + ); + } + Ok(response.json().await?) + } + + #[tracing::instrument(skip(self))] + async fn api_get_paginated(&self, path: &str) -> Result> { + let mut items = Vec::new(); + let mut next_url = Some(self.api_url(path)); + let mut pages = 0; + + while let Some(url) = next_url { + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/json"); + if let Some((key, secret)) = &self.config.bitbucket_oauth { + req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}")); + } + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "Bitbucket API paginated response"); + if !response.status().is_success() { + break; + } + let data: serde_json::Value = response.json().await?; + if let Some(values) = data["values"].as_array() { + items.extend(values.iter().cloned()); + } + next_url = data["next"].as_str().map(|s: &str| s.to_string()); + pages += 1; + if pages > 10 { + break; + } + } + Ok(items) + } + + async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { + if self.git_driver.is_none() { + let git_url = format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo); + let mut driver = GitDriver::new(&git_url, self.config.clone()); + driver.initialize().await?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriverInterface for GitBitbucketDriver { + async fn initialize(&mut self) -> Result<()> { + match self.api_get("").await { + Ok(data) => { + if let Some(scm) = data["scm"].as_str() { + self.vcs_type = scm.to_string(); + } + let default_branch = data["mainbranch"]["name"] + .as_str() + .unwrap_or("main") + .to_string(); + self.root_identifier = Some(default_branch); + } + Err(_) => { + self.api_failed = true; + let driver = self.use_git_fallback().await?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let branches = driver.branches().await?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/refs/branches?pagelen=100").await?; + let mut branches = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["target"]["hash"].as_str()) + { + branches.insert(name.to_string(), sha.to_string()); + } + } + self.branches = Some(branches); + } + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let tags = driver.tags().await?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/refs/tags?pagelen=100").await?; + let mut tags = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["target"]["hash"].as_str()) + { + tags.insert(name.to_string(), sha.to_string()); + } + } + self.tags = Some(tags); + } + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier).await?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + let url = self.api_url(&format!("/src/{identifier}/{file}")); + let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1"); + if let Some((key, secret)) = &self.config.bitbucket_oauth { + req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}")); + } + let response = req.send().await?; + if response.status().is_success() { + Ok(Some(response.text().await?)) + } else { + Ok(None) + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + match self.api_get(&format!("/commit/{identifier}")).await { + Ok(data) => Ok(data["date"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + async fn dist(&self, identifier: &str) -> Result> { + Ok(Some(DistReference { + dist_type: "zip".to_string(), + url: format!( + "https://bitbucket.org/{}/{}/get/{}.zip", + self.owner, self.repo, identifier, + ), + reference: identifier.to_string(), + shasum: None, + })) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: self.vcs_type.clone(), + url: format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup().await?; + } + Ok(()) + } +} diff --git a/crates/mozart-core/src/repository/vcs/git_driver.rs b/crates/mozart-core/src/repository/vcs/git_driver.rs new file mode 100644 index 0000000..d2f9c04 --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/git_driver.rs @@ -0,0 +1,275 @@ +use crate::repository::vcs::{DistReference, DriverConfig, SourceReference, VcsDriverInterface}; +use crate::vcs::process::ProcessExecutor; +use crate::vcs::util::git::GitUtil; +use anyhow::Result; +use indexmap::IndexMap; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Git VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\GitDriver`. +pub struct GitDriver { + url: String, + repo_dir: Option, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + git_util: GitUtil, + is_local: bool, +} + +impl GitDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let is_local = Self::is_local_path(url); + let process = ProcessExecutor::new(); + let git_util = GitUtil::new(process, config.cache_vcs_dir.clone()); + Self { + url: url.to_string(), + repo_dir: if is_local { + Some(PathBuf::from(url)) + } else { + None + }, + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + git_util, + is_local, + } + } + + /// Check if a URL is supported by the Git driver. + pub fn supports(url: &str) -> bool { + if Self::is_local_path(url) { + return Path::new(url).join(".git").is_dir() || url.ends_with(".git"); + } + url.starts_with("git://") + || url.starts_with("git@") + || url.ends_with(".git") + || url.contains("git.") + } + + fn is_local_path(url: &str) -> bool { + !url.contains("://") && !url.starts_with("git@") && Path::new(url).exists() + } + + fn get_repo_dir(&self) -> Result<&Path> { + self.repo_dir + .as_deref() + .ok_or_else(|| anyhow::anyhow!("GitDriver not initialized")) + } + + fn parse_branches(output: &str) -> BTreeMap { + let mut branches = BTreeMap::new(); + for line in output.lines() { + let line = line.trim(); + if line.is_empty() || line.contains("HEAD detached") || line.contains("->") { + continue; + } + // Remove leading "* " for current branch + let line = line.strip_prefix("* ").unwrap_or(line); + // Format: "branch_name commit_hash ..." + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + branches.insert(parts[0].to_string(), parts[1].to_string()); + } + } + branches + } + + fn parse_tags(output: &str) -> BTreeMap { + let mut tags = BTreeMap::new(); + // First pass: collect dereferenced tags (^{}) + let mut dereferenced = IndexMap::new(); + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Format: "commit_hash refs/tags/tag_name" or "commit_hash refs/tags/tag_name^{}" + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let hash = parts[0]; + let refname = parts[1]; + if let Some(tag_name) = refname.strip_prefix("refs/tags/") + && let Some(tag_name) = tag_name.strip_suffix("^{}") + { + // Dereferenced tag - this is the actual commit + dereferenced.insert(tag_name.to_string(), hash.to_string()); + } + } + } + // Second pass: collect all tags, preferring dereferenced values + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let hash = parts[0]; + let refname = parts[1]; + if let Some(tag_name) = refname.strip_prefix("refs/tags/") { + if tag_name.ends_with("^{}") { + continue; // Skip dereferenced entries themselves + } + let resolved = dereferenced + .get(tag_name) + .cloned() + .unwrap_or_else(|| hash.to_string()); + tags.insert(tag_name.to_string(), resolved); + } + } + } + tags + } +} + +impl VcsDriverInterface for GitDriver { + async fn initialize(&mut self) -> Result<()> { + if self.is_local { + // Local repo: use directly (or its .git subdir) + let path = Path::new(&self.url); + if path.join(".git").is_dir() { + self.repo_dir = Some(path.join(".git")); + } else { + self.repo_dir = Some(path.to_path_buf()); + } + } else { + // Remote repo: sync mirror + let mirror_dir = self.git_util.sync_mirror(&self.url)?; + self.repo_dir = Some(mirror_dir); + } + + // Determine root identifier (default branch) + let repo_dir = self.repo_dir.clone().unwrap(); + if let Ok(Some(branch)) = self.git_util.get_default_branch(&repo_dir) { + self.root_identifier = Some(branch); + } else { + // Fallback: try common branch names + let process = ProcessExecutor::new(); + for name in &["main", "master"] { + let output = + process.execute(&["git", "rev-parse", "--verify", name], Some(&repo_dir))?; + if output.status == 0 { + self.root_identifier = Some(name.to_string()); + break; + } + } + } + + if self.root_identifier.is_none() { + self.root_identifier = Some("master".to_string()); + } + + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("master") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + let repo_dir = self.get_repo_dir()?.to_path_buf(); + let process = ProcessExecutor::new(); + let output = process.execute_checked( + &["git", "branch", "--no-color", "--no-abbrev", "-v"], + Some(&repo_dir), + )?; + self.branches = Some(Self::parse_branches(&output.stdout)); + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + let repo_dir = self.get_repo_dir()?.to_path_buf(); + let process = ProcessExecutor::new(); + let output = process.execute( + &["git", "show-ref", "--tags", "--dereference"], + Some(&repo_dir), + )?; + self.tags = Some(if output.status == 0 { + Self::parse_tags(&output.stdout) + } else { + BTreeMap::new() + }); + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + + let content = self.file_content("composer.json", identifier).await?; + let value = match content { + Some(c) => serde_json::from_str(&c).ok(), + None => None, + }; + + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + let repo_dir = self.get_repo_dir()?; + let process = ProcessExecutor::new(); + let resource = format!("{identifier}:{file}"); + let output = process.execute(&["git", "show", &resource], Some(repo_dir))?; + if output.status == 0 { + Ok(Some(output.stdout)) + } else { + Ok(None) + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + let repo_dir = self.get_repo_dir()?; + let process = ProcessExecutor::new(); + let output = process.execute( + &["git", "log", "-1", "--format=%aI", identifier], + Some(repo_dir), + )?; + if output.status == 0 { + let date = output.stdout.trim().to_string(); + if date.is_empty() { + Ok(None) + } else { + Ok(Some(date)) + } + } else { + Ok(None) + } + } + + async fn dist(&self, _identifier: &str) -> Result> { + // Plain git repos don't provide dist archives + Ok(None) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "git".to_string(), + url: self.url.clone(), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/crates/mozart-core/src/repository/vcs/github_driver.rs b/crates/mozart-core/src/repository/vcs/github_driver.rs new file mode 100644 index 0000000..7a8bc98 --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/github_driver.rs @@ -0,0 +1,314 @@ +use crate::repository::vcs::{ + DistReference, DriverConfig, GitDriver, SourceReference, VcsDriverInterface, +}; +use anyhow::{Result, bail}; +use indexmap::IndexMap; +use regex::Regex; +use reqwest::Client; +use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; +use std::collections::BTreeMap; + +/// GitHub VCS driver using the REST API v3. +/// +/// Falls back to `GitDriver` when API access fails. +pub struct GitHubDriver { + owner: String, + repo: String, + url: String, + root_identifier: Option, + tags: Option>, + branches: Option>, + repo_data: Option, + info_cache: IndexMap>, + git_driver: Option>, + http_client: Client, + config: DriverConfig, + api_failed: bool, +} + +impl GitHubDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let (owner, repo) = Self::parse_url(url).unwrap_or_default(); + Self { + owner, + repo, + url: url.to_string(), + root_identifier: None, + tags: None, + branches: None, + repo_data: None, + info_cache: IndexMap::new(), + git_driver: None, + http_client: crate::http::default_client(), + config, + api_failed: false, + } + } + + /// Check if a URL points to GitHub. + pub fn supports(url: &str) -> bool { + let url_lower = url.to_lowercase(); + url_lower.contains("github.com") + && (url_lower.contains("github.com/") || url_lower.contains("github.com:")) + } + + fn parse_url(url: &str) -> Option<(String, String)> { + let re = Regex::new(r"github\.com[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; + let caps = re.captures(url)?; + Some((caps[1].to_string(), caps[2].to_string())) + } + + fn api_url(&self, path: &str) -> String { + format!( + "https://api.github.com/repos/{}/{}{}", + self.owner, self.repo, path + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result { + let url = self.api_url(path); + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/vnd.github.v3+json"); + + if let Some(token) = &self.config.github_token { + req = req.header(AUTHORIZATION, format!("token {token}")); + } + + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "GitHub API response"); + if !response.status().is_success() { + bail!( + "GitHub API request to {} failed with status {}", + url, + response.status() + ); + } + Ok(response.json().await?) + } + + #[tracing::instrument(skip(self))] + async fn api_get_paginated(&self, path: &str) -> Result> { + let mut items = Vec::new(); + let mut page = 1; + loop { + let separator = if path.contains('?') { "&" } else { "?" }; + let url = format!( + "https://api.github.com/repos/{}/{}{}{}per_page=100&page={}", + self.owner, self.repo, path, separator, page, + ); + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/vnd.github.v3+json"); + if let Some(token) = &self.config.github_token { + req = req.header(AUTHORIZATION, format!("token {token}")); + } + + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "GitHub API paginated response"); + if !response.status().is_success() { + bail!("GitHub API paginated request failed: {}", response.status()); + } + + let batch: Vec = response.json().await?; + if batch.is_empty() { + break; + } + items.extend(batch); + page += 1; + // Safety: limit to 10 pages (1000 items) + if page > 10 { + break; + } + } + Ok(items) + } + + async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { + if self.git_driver.is_none() { + let git_url = format!("https://github.com/{}/{}.git", self.owner, self.repo); + let mut driver = GitDriver::new(&git_url, self.config.clone()); + driver.initialize().await?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriverInterface for GitHubDriver { + async fn initialize(&mut self) -> Result<()> { + // Try to fetch repo data from API + match self.api_get("").await { + Ok(data) => { + let default_branch = data["default_branch"] + .as_str() + .unwrap_or("main") + .to_string(); + self.root_identifier = Some(default_branch); + self.repo_data = Some(data); + } + Err(_) => { + self.api_failed = true; + let driver = self.use_git_fallback().await?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let branches = driver.branches().await?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/branches").await?; + let mut branches = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["commit"]["sha"].as_str()) + { + branches.insert(name.to_string(), sha.to_string()); + } + } + self.branches = Some(branches); + } + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let tags = driver.tags().await?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/tags").await?; + let mut tags = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["commit"]["sha"].as_str()) + { + tags.insert(name.to_string(), sha.to_string()); + } + } + self.tags = Some(tags); + } + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + + let content = self.file_content("composer.json", identifier).await?; + let value = match content { + Some(c) => serde_json::from_str(&c).ok(), + None => None, + }; + + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + if self.api_failed { + // Can't use API, would need git fallback + // For simplicity, return None (git_driver is mutable) + return Ok(None); + } + + let path = format!("/contents/{}?ref={}", file, identifier); + match self.api_get(&path).await { + Ok(data) => { + if let Some(content) = data["content"].as_str() { + // GitHub returns base64-encoded content + let decoded = base64_decode_content(content)?; + Ok(Some(decoded)) + } else { + Ok(None) + } + } + Err(_) => Ok(None), + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + + let path = format!("/commits/{}", identifier); + match self.api_get(&path).await { + Ok(data) => { + let date = data["commit"]["committer"]["date"] + .as_str() + .map(|s| s.to_string()); + Ok(date) + } + Err(_) => Ok(None), + } + } + + async fn dist(&self, identifier: &str) -> Result> { + Ok(Some(DistReference { + dist_type: "zip".to_string(), + url: format!( + "https://api.github.com/repos/{}/{}/zipball/{}", + self.owner, self.repo, identifier, + ), + reference: identifier.to_string(), + shasum: None, + })) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "git".to_string(), + url: format!("https://github.com/{}/{}.git", self.owner, self.repo), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup().await?; + } + Ok(()) + } +} + +/// Decode base64-encoded content from API responses. +/// Also used by Forgejo driver as `base64_decode_content`. +pub fn base64_decode_content(input: &str) -> Result { + use base64::Engine as _; + let cleaned: Vec = input + .bytes() + .filter(|&b| b != b'\n' && b != b'\r') + .collect(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(&cleaned) + .map_err(|e| anyhow::anyhow!("Base64 decode error: {e}"))?; + String::from_utf8(decoded).map_err(|e| anyhow::anyhow!("Invalid UTF-8 in base64 content: {e}")) +} diff --git a/crates/mozart-core/src/repository/vcs/gitlab_driver.rs b/crates/mozart-core/src/repository/vcs/gitlab_driver.rs new file mode 100644 index 0000000..6b5e7af --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/gitlab_driver.rs @@ -0,0 +1,300 @@ +use crate::repository::vcs::{ + DistReference, DriverConfig, GitDriver, SourceReference, VcsDriverInterface, +}; +use anyhow::{Result, bail}; +use indexmap::IndexMap; +use regex::Regex; +use reqwest::Client; +use reqwest::header::{ACCEPT, USER_AGENT}; +use std::collections::BTreeMap; + +/// GitLab VCS driver using the REST API v4. +/// +/// Supports self-hosted GitLab instances. +pub struct GitLabDriver { + owner: String, + repo: String, + host: String, + scheme: String, + url: String, + project_id: Option, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + git_driver: Option>, + http_client: Client, + config: DriverConfig, + api_failed: bool, +} + +impl GitLabDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default(); + Self { + owner, + repo, + host, + scheme, + url: url.to_string(), + project_id: None, + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + git_driver: None, + http_client: crate::http::default_client(), + config, + api_failed: false, + } + } + + pub fn supports(url: &str, gitlab_domains: &[String]) -> bool { + let url_lower = url.to_lowercase(); + for domain in gitlab_domains { + if url_lower.contains(domain) { + return true; + } + } + false + } + + fn parse_url(url: &str) -> Option<(String, String, String, String)> { + let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") + .ok()?; + let caps = re.captures(url)?; + Some(( + caps[2].to_string(), + caps[1].to_string(), + caps[3].to_string(), + caps[4].to_string(), + )) + } + + fn api_url(&self, path: &str) -> String { + let project_path = format!("{}%2F{}", self.owner, self.repo); + let id = self.project_id.as_deref().unwrap_or(&project_path); + format!( + "{}://{}/api/v4/projects/{}{}", + self.scheme, self.host, id, path + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result { + let url = self.api_url(path); + let mut req = self + .http_client + .get(&url) + .header(USER_AGENT, "mozart/0.1") + .header(ACCEPT, "application/json"); + + if let Some(token) = &self.config.gitlab_token { + req = req.header("PRIVATE-TOKEN", token.as_str()); + } + + let response = req.send().await?; + tracing::debug!(status = %response.status(), %url, "GitLab API response"); + if !response.status().is_success() { + bail!( + "GitLab API request to {} failed with status {}", + url, + response.status() + ); + } + Ok(response.json().await?) + } + + #[tracing::instrument(skip(self))] + async fn api_get_paginated(&self, path: &str) -> Result> { + let mut items = Vec::new(); + let mut page = 1; + loop { + let sep = if path.contains('?') { "&" } else { "?" }; + let paged_path = format!("{path}{sep}per_page=100&page={page}"); + let data = self.api_get(&paged_path).await?; + let batch: Vec = match data { + serde_json::Value::Array(arr) => arr, + _ => break, + }; + if batch.is_empty() { + break; + } + items.extend(batch); + page += 1; + if page > 10 { + break; + } + } + Ok(items) + } + + async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { + if self.git_driver.is_none() { + let git_url = format!( + "{}://{}/{}/{}.git", + self.scheme, self.host, self.owner, self.repo + ); + let mut driver = GitDriver::new(&git_url, self.config.clone()); + driver.initialize().await?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriverInterface for GitLabDriver { + async fn initialize(&mut self) -> Result<()> { + match self.api_get("").await { + Ok(data) => { + if let Some(id) = data["id"].as_u64() { + self.project_id = Some(id.to_string()); + } + let default_branch = data["default_branch"] + .as_str() + .unwrap_or("main") + .to_string(); + self.root_identifier = Some(default_branch); + } + Err(_) => { + self.api_failed = true; + let driver = self.use_git_fallback().await?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let branches = driver.branches().await?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/repository/branches").await?; + let mut branches = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["commit"]["id"].as_str()) + { + branches.insert(name.to_string(), sha.to_string()); + } + } + self.branches = Some(branches); + } + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback().await?; + let tags = driver.tags().await?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/repository/tags").await?; + let mut tags = BTreeMap::new(); + for item in items { + if let (Some(name), Some(sha)) = + (item["name"].as_str(), item["commit"]["id"].as_str()) + { + tags.insert(name.to_string(), sha.to_string()); + } + } + self.tags = Some(tags); + } + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier).await?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + let encoded_file = file.replace('/', "%2F"); + let path = format!("/repository/files/{}/raw?ref={}", encoded_file, identifier); + let url = self.api_url(&path); + let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1"); + if let Some(token) = &self.config.gitlab_token { + req = req.header("PRIVATE-TOKEN", token.as_str()); + } + let response = req.send().await?; + if response.status().is_success() { + Ok(Some(response.text().await?)) + } else { + Ok(None) + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + if self.api_failed { + return Ok(None); + } + match self + .api_get(&format!("/repository/commits/{identifier}")) + .await + { + Ok(data) => Ok(data["committed_date"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + async fn dist(&self, identifier: &str) -> Result> { + Ok(Some(DistReference { + dist_type: "zip".to_string(), + url: format!( + "{}://{}/api/v4/projects/{}/repository/archive.zip?sha={}", + self.scheme, + self.host, + self.project_id + .as_deref() + .unwrap_or(&format!("{}%2F{}", self.owner, self.repo)), + identifier, + ), + reference: identifier.to_string(), + shasum: None, + })) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "git".to_string(), + url: format!( + "{}://{}/{}/{}.git", + self.scheme, self.host, self.owner, self.repo + ), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup().await?; + } + Ok(()) + } +} diff --git a/crates/mozart-core/src/repository/vcs/hg_driver.rs b/crates/mozart-core/src/repository/vcs/hg_driver.rs new file mode 100644 index 0000000..525b64f --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/hg_driver.rs @@ -0,0 +1,203 @@ +use crate::repository::vcs::{DistReference, DriverConfig, SourceReference, VcsDriverInterface}; +use crate::vcs::process::ProcessExecutor; +use crate::vcs::util::git::GitUtil; +use crate::vcs::util::hg::HgUtil; +use anyhow::Result; +use indexmap::IndexMap; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Mercurial VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\HgDriver`. +pub struct HgDriver { + url: String, + repo_dir: Option, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + hg_util: HgUtil, + config: DriverConfig, +} + +impl HgDriver { + pub fn new(url: &str, config: DriverConfig) -> Self { + let process = ProcessExecutor::new(); + Self { + url: url.to_string(), + repo_dir: None, + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + hg_util: HgUtil::new(process), + config, + } + } + + pub fn supports(url: &str) -> bool { + url.starts_with("hg://") || url.contains("hg.") || url.ends_with(".hg") + } + + fn get_repo_dir(&self) -> Result<&PathBuf> { + self.repo_dir + .as_ref() + .ok_or_else(|| anyhow::anyhow!("HgDriver not initialized")) + } +} + +impl VcsDriverInterface for HgDriver { + async fn initialize(&mut self) -> Result<()> { + let cache_dir = &self.config.cache_vcs_dir; + std::fs::create_dir_all(cache_dir)?; + let repo_dir = cache_dir.join(GitUtil::sanitize_url(&self.url)); + + if repo_dir.join(".hg").is_dir() { + // Update existing clone + self.hg_util.execute(&["pull"], Some(&repo_dir))?; + } else { + // Clone without checkout + let dir_str = repo_dir.to_string_lossy().to_string(); + self.hg_util + .execute(&["clone", "--noupdate", &self.url, &dir_str], None)?; + } + + self.repo_dir = Some(repo_dir.clone()); + + // Get default branch + let output = self.hg_util.execute( + &["log", "-r", "default", "--template", "{node|short}"], + Some(&repo_dir), + ); + self.root_identifier = match output { + Ok(o) if !o.stdout.trim().is_empty() => Some("default".to_string()), + _ => Some("tip".to_string()), + }; + + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("default") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + let repo_dir = self.get_repo_dir()?.clone(); + let mut branches = BTreeMap::new(); + + // Named branches + let output = self.hg_util.execute(&["branches", "-q"], Some(&repo_dir))?; + for name in ProcessExecutor::split_lines(&output.stdout) { + let name = name.trim(); + let rev_output = self.hg_util.execute( + &["log", "-r", name, "--template", "{node}"], + Some(&repo_dir), + )?; + branches.insert(name.to_string(), rev_output.stdout.trim().to_string()); + } + + // Bookmarks + let output = self + .hg_util + .execute_unchecked(&["bookmarks", "-q"], Some(&repo_dir))?; + if output.status == 0 { + for name in ProcessExecutor::split_lines(&output.stdout) { + let name = name.trim(); + if !branches.contains_key(name) { + let rev_output = self.hg_util.execute( + &["log", "-r", name, "--template", "{node}"], + Some(&repo_dir), + )?; + branches.insert(name.to_string(), rev_output.stdout.trim().to_string()); + } + } + } + + self.branches = Some(branches); + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + let repo_dir = self.get_repo_dir()?.clone(); + let output = self.hg_util.execute(&["tags", "-q"], Some(&repo_dir))?; + let mut tags = BTreeMap::new(); + for name in ProcessExecutor::split_lines(&output.stdout) { + let name = name.trim(); + if name == "tip" { + continue; // Skip the "tip" pseudo-tag + } + let rev_output = self.hg_util.execute( + &["log", "-r", name, "--template", "{node}"], + Some(&repo_dir), + )?; + tags.insert(name.to_string(), rev_output.stdout.trim().to_string()); + } + self.tags = Some(tags); + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier).await?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + let repo_dir = self.get_repo_dir()?; + let output = self + .hg_util + .execute_unchecked(&["cat", "-r", identifier, "--", file], Some(repo_dir))?; + if output.status == 0 { + Ok(Some(output.stdout)) + } else { + Ok(None) + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + let repo_dir = self.get_repo_dir()?; + let output = self.hg_util.execute( + &["log", "-r", identifier, "--template", "{date|isodatesec}"], + Some(repo_dir), + )?; + let date = output.stdout.trim().to_string(); + if date.is_empty() { + Ok(None) + } else { + Ok(Some(date)) + } + } + + async fn dist(&self, _identifier: &str) -> Result> { + Ok(None) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "hg".to_string(), + url: self.url.clone(), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/crates/mozart-core/src/repository/vcs/perforce_driver.rs b/crates/mozart-core/src/repository/vcs/perforce_driver.rs new file mode 100644 index 0000000..0f9a237 --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/perforce_driver.rs @@ -0,0 +1 @@ +pub struct PerforceDriver; diff --git a/crates/mozart-core/src/repository/vcs/svn_driver.rs b/crates/mozart-core/src/repository/vcs/svn_driver.rs new file mode 100644 index 0000000..cfd6703 --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/svn_driver.rs @@ -0,0 +1,214 @@ +use crate::repository::vcs::{DistReference, DriverConfig, SourceReference, VcsDriverInterface}; +use crate::vcs::process::ProcessExecutor; +use crate::vcs::util::svn::SvnUtil; +use anyhow::Result; +use indexmap::IndexMap; +use regex::Regex; +use std::collections::BTreeMap; + +/// SVN VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\SvnDriver`. +pub struct SvnDriver { + url: String, + base_url: String, + trunk_path: String, + branches_path: String, + tags_path: String, + root_identifier: Option, + tags: Option>, + branches: Option>, + info_cache: IndexMap>, + svn_util: SvnUtil, +} + +impl SvnDriver { + pub fn new(url: &str, _config: DriverConfig) -> Self { + let process = ProcessExecutor::new(); + Self { + url: url.to_string(), + base_url: url.to_string(), + trunk_path: "trunk".to_string(), + branches_path: "branches".to_string(), + tags_path: "tags".to_string(), + root_identifier: None, + tags: None, + branches: None, + info_cache: IndexMap::new(), + svn_util: SvnUtil::new(process), + } + } + + pub fn supports(url: &str) -> bool { + url.starts_with("svn://") || url.starts_with("svn+ssh://") + } + + fn svn_info(&self, url: &str) -> Result { + let output = self.svn_util.execute(&["info", "--xml", url], None)?; + // Parse minimal info from XML output + let stdout = &output.stdout; + let mut info = serde_json::Map::new(); + + if let Some(rev) = extract_xml_attr(stdout, "entry", "revision") { + info.insert("revision".to_string(), serde_json::Value::String(rev)); + } + if let Some(url_val) = extract_xml_content(stdout, "url") { + info.insert("url".to_string(), serde_json::Value::String(url_val)); + } + if let Some(date) = extract_xml_content(stdout, "date") { + info.insert("date".to_string(), serde_json::Value::String(date)); + } + + Ok(serde_json::Value::Object(info)) + } + + fn svn_ls(&self, url: &str) -> Result> { + let output = self.svn_util.execute(&["ls", url], None)?; + Ok(ProcessExecutor::split_lines(&output.stdout) + .into_iter() + .map(|s| s.trim_end_matches('/').to_string()) + .collect()) + } +} + +impl VcsDriverInterface for SvnDriver { + async fn initialize(&mut self) -> Result<()> { + let info = self.svn_info(&self.url)?; + if let Some(url) = info["url"].as_str() { + self.base_url = url.to_string(); + } + self.root_identifier = info["revision"].as_str().map(|s| s.to_string()); + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("HEAD") + } + + async fn branches(&mut self) -> Result<&BTreeMap> { + if self.branches.is_none() { + let mut branches = BTreeMap::new(); + + // Add trunk + let trunk_url = format!("{}/{}", self.base_url, self.trunk_path); + if let Ok(info) = self.svn_info(&trunk_url) + && let Some(rev) = info["revision"].as_str() + { + branches.insert("trunk".to_string(), rev.to_string()); + } + + // List branches directory + let branches_url = format!("{}/{}", self.base_url, self.branches_path); + if let Ok(items) = self.svn_ls(&branches_url) { + for name in items { + let branch_url = format!("{}/{}", branches_url, name); + if let Ok(info) = self.svn_info(&branch_url) + && let Some(rev) = info["revision"].as_str() + { + branches.insert(name, rev.to_string()); + } + } + } + + self.branches = Some(branches); + } + Ok(self.branches.as_ref().unwrap()) + } + + async fn tags(&mut self) -> Result<&BTreeMap> { + if self.tags.is_none() { + let mut tags = BTreeMap::new(); + let tags_url = format!("{}/{}", self.base_url, self.tags_path); + if let Ok(items) = self.svn_ls(&tags_url) { + for name in items { + let tag_url = format!("{}/{}", tags_url, name); + if let Ok(info) = self.svn_info(&tag_url) + && let Some(rev) = info["revision"].as_str() + { + tags.insert(name, rev.to_string()); + } + } + } + self.tags = Some(tags); + } + Ok(self.tags.as_ref().unwrap()) + } + + async fn composer_information( + &mut self, + identifier: &str, + ) -> Result> { + if let Some(cached) = self.info_cache.get(identifier) { + return Ok(cached.clone()); + } + let content = self.file_content("composer.json", identifier).await?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + async fn file_content(&self, file: &str, identifier: &str) -> Result> { + // identifier is either a path (trunk, branches/x, tags/y) or a revision number + let url = if identifier.contains('/') || identifier == "trunk" { + format!("{}/{}/{}", self.base_url, identifier, file) + } else { + format!( + "{}/{}/{}@{}", + self.base_url, self.trunk_path, file, identifier + ) + }; + let output = self.svn_util.execute(&["cat", &url], None); + match output { + Ok(o) if !o.stdout.is_empty() => Ok(Some(o.stdout)), + _ => Ok(None), + } + } + + async fn change_date(&self, identifier: &str) -> Result> { + let url = if identifier.contains('/') || identifier == "trunk" { + format!("{}/{}", self.base_url, identifier) + } else { + format!("{}@{}", self.base_url, identifier) + }; + match self.svn_info(&url) { + Ok(info) => Ok(info["date"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + async fn dist(&self, _identifier: &str) -> Result> { + // SVN doesn't provide dist archives + Ok(None) + } + + fn source(&self, identifier: &str) -> SourceReference { + SourceReference { + source_type: "svn".to_string(), + url: self.base_url.clone(), + reference: identifier.to_string(), + } + } + + fn url(&self) -> &str { + &self.url + } + + async fn cleanup(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Extract an XML attribute value from a simple XML string. +fn extract_xml_attr(xml: &str, tag: &str, attr: &str) -> Option { + let pattern = format!(r#"<{tag}\s[^>]*{attr}="([^"]*)"#); + let re = Regex::new(&pattern).ok()?; + re.captures(xml).map(|c| c[1].to_string()) +} + +/// Extract text content between XML tags. +fn extract_xml_content(xml: &str, tag: &str) -> Option { + let pattern = format!(r"<{tag}>([^<]*)"); + let re = Regex::new(&pattern).ok()?; + re.captures(xml).map(|c| c[1].to_string()) +} diff --git a/crates/mozart-core/src/repository/vcs/vcs_driver.rs b/crates/mozart-core/src/repository/vcs/vcs_driver.rs new file mode 100644 index 0000000..abecc0e --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/vcs_driver.rs @@ -0,0 +1 @@ +pub struct VcsDriver; diff --git a/crates/mozart-core/src/repository/vcs/vcs_driver_interface.rs b/crates/mozart-core/src/repository/vcs/vcs_driver_interface.rs new file mode 100644 index 0000000..49cbd98 --- /dev/null +++ b/crates/mozart-core/src/repository/vcs/vcs_driver_interface.rs @@ -0,0 +1,43 @@ +use crate::repository::vcs::{DistReference, SourceReference}; + +/// The VCS driver interface. +/// +/// Corresponds to Composer's `VcsDriverInterface`. +#[allow(async_fn_in_trait)] +pub trait VcsDriverInterface { + /// Initialize the driver (e.g., clone mirror, fetch API metadata). + async fn initialize(&mut self) -> anyhow::Result<()>; + + /// The root identifier (default branch/trunk). + fn root_identifier(&self) -> &str; + + /// All branches as `name -> commit_hash`. + async fn branches(&mut self) -> anyhow::Result<&std::collections::BTreeMap>; + + /// All tags as `name -> commit_hash`. + async fn tags(&mut self) -> anyhow::Result<&std::collections::BTreeMap>; + + /// Get composer.json content parsed as JSON for a given identifier. + async fn composer_information( + &mut self, + identifier: &str, + ) -> anyhow::Result>; + + /// Get raw file content at a given path and identifier. + async fn file_content(&self, file: &str, identifier: &str) -> anyhow::Result>; + + /// Get the change date for a given identifier (ISO 8601). + async fn change_date(&self, identifier: &str) -> anyhow::Result>; + + /// Get the dist reference for a given identifier. + async fn dist(&self, identifier: &str) -> anyhow::Result>; + + /// Get the source reference for a given identifier. + fn source(&self, identifier: &str) -> SourceReference; + + /// The canonical URL of this repository. + fn url(&self) -> &str; + + /// Clean up resources (temp dirs, etc.). + async fn cleanup(&mut self) -> anyhow::Result<()>; +} diff --git a/crates/mozart-core/src/repository/vcs_bridge.rs b/crates/mozart-core/src/repository/vcs_bridge.rs index 37d066b..18a8420 100644 --- a/crates/mozart-core/src/repository/vcs_bridge.rs +++ b/crates/mozart-core/src/repository/vcs_bridge.rs @@ -3,11 +3,11 @@ //! Scans VCS repositories defined in composer.json and converts //! discovered package versions into pool inputs for the SAT resolver. -use super::packagist::PackagistVersion; -use super::resolver::{parse_normalized, version_stability}; use crate::dependency_resolver::{PoolPackageInput, make_pool_links}; use crate::package::{RawRepository, Stability}; -use crate::vcs::driver::DriverConfig; +use crate::repository::packagist::PackagistVersion; +use crate::repository::resolver::{parse_normalized, version_stability}; +use crate::repository::vcs::DriverConfig; use crate::vcs::repository::{VcsPackageVersion, VcsRepository}; use indexmap::IndexMap; use std::collections::BTreeMap; diff --git a/crates/mozart-core/src/vcs.rs b/crates/mozart-core/src/vcs.rs index 11db58d..137cc5b 100644 --- a/crates/mozart-core/src/vcs.rs +++ b/crates/mozart-core/src/vcs.rs @@ -1,5 +1,4 @@ pub mod downloader; -pub mod driver; pub mod process; pub mod repository; pub mod util; diff --git a/crates/mozart-core/src/vcs/driver/bitbucket.rs b/crates/mozart-core/src/vcs/driver/bitbucket.rs deleted file mode 100644 index 2235e10..0000000 --- a/crates/mozart-core/src/vcs/driver/bitbucket.rs +++ /dev/null @@ -1,277 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use anyhow::{Result, bail}; -use regex::Regex; -use reqwest::Client; -use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; - -use super::git::GitDriver; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// Bitbucket VCS driver using the REST API 2.0. -pub struct BitbucketDriver { - owner: String, - repo: String, - url: String, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - git_driver: Option>, - http_client: Client, - config: DriverConfig, - api_failed: bool, - vcs_type: String, // "git" or "hg" -} - -impl BitbucketDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let (owner, repo) = Self::parse_url(url).unwrap_or_default(); - Self { - owner, - repo, - url: url.to_string(), - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - git_driver: None, - http_client: crate::http::default_client(), - config, - api_failed: false, - vcs_type: "git".to_string(), - } - } - - pub fn supports(url: &str) -> bool { - let url_lower = url.to_lowercase(); - url_lower.contains("bitbucket.org") - } - - fn parse_url(url: &str) -> Option<(String, String)> { - let re = - Regex::new(r"bitbucket\.org[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; - let caps = re.captures(url)?; - Some((caps[1].to_string(), caps[2].to_string())) - } - - fn api_url(&self, path: &str) -> String { - format!( - "https://api.bitbucket.org/2.0/repositories/{}/{}{}", - self.owner, self.repo, path, - ) - } - - #[tracing::instrument(skip(self))] - async fn api_get(&self, path: &str) -> Result { - let url = self.api_url(path); - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/json"); - - if let Some((key, secret)) = &self.config.bitbucket_oauth { - let credentials = format!("{key}:{secret}"); - req = req.header(AUTHORIZATION, format!("Basic {credentials}")); - } - - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "Bitbucket API response"); - if !response.status().is_success() { - bail!( - "Bitbucket API request to {} failed: {}", - url, - response.status() - ); - } - Ok(response.json().await?) - } - - #[tracing::instrument(skip(self))] - async fn api_get_paginated(&self, path: &str) -> Result> { - let mut items = Vec::new(); - let mut next_url = Some(self.api_url(path)); - let mut pages = 0; - - while let Some(url) = next_url { - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/json"); - if let Some((key, secret)) = &self.config.bitbucket_oauth { - req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}")); - } - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "Bitbucket API paginated response"); - if !response.status().is_success() { - break; - } - let data: serde_json::Value = response.json().await?; - if let Some(values) = data["values"].as_array() { - items.extend(values.iter().cloned()); - } - next_url = data["next"].as_str().map(|s: &str| s.to_string()); - pages += 1; - if pages > 10 { - break; - } - } - Ok(items) - } - - async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { - if self.git_driver.is_none() { - let git_url = format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo); - let mut driver = GitDriver::new(&git_url, self.config.clone()); - driver.initialize().await?; - self.git_driver = Some(Box::new(driver)); - } - Ok(self.git_driver.as_mut().unwrap()) - } -} - -impl VcsDriver for BitbucketDriver { - async fn initialize(&mut self) -> Result<()> { - match self.api_get("").await { - Ok(data) => { - if let Some(scm) = data["scm"].as_str() { - self.vcs_type = scm.to_string(); - } - let default_branch = data["mainbranch"]["name"] - .as_str() - .unwrap_or("main") - .to_string(); - self.root_identifier = Some(default_branch); - } - Err(_) => { - self.api_failed = true; - let driver = self.use_git_fallback().await?; - self.root_identifier = Some(driver.root_identifier().to_string()); - } - } - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("main") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let branches = driver.branches().await?.clone(); - self.branches = Some(branches); - } else { - let items = self.api_get_paginated("/refs/branches?pagelen=100").await?; - let mut branches = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["target"]["hash"].as_str()) - { - branches.insert(name.to_string(), sha.to_string()); - } - } - self.branches = Some(branches); - } - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let tags = driver.tags().await?.clone(); - self.tags = Some(tags); - } else { - let items = self.api_get_paginated("/refs/tags?pagelen=100").await?; - let mut tags = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["target"]["hash"].as_str()) - { - tags.insert(name.to_string(), sha.to_string()); - } - } - self.tags = Some(tags); - } - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - let content = self.file_content("composer.json", identifier).await?; - let value = content.and_then(|c| serde_json::from_str(&c).ok()); - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - let url = self.api_url(&format!("/src/{identifier}/{file}")); - let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1"); - if let Some((key, secret)) = &self.config.bitbucket_oauth { - req = req.header(AUTHORIZATION, format!("Basic {key}:{secret}")); - } - let response = req.send().await?; - if response.status().is_success() { - Ok(Some(response.text().await?)) - } else { - Ok(None) - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - match self.api_get(&format!("/commit/{identifier}")).await { - Ok(data) => Ok(data["date"].as_str().map(|s| s.to_string())), - Err(_) => Ok(None), - } - } - - async fn dist(&self, identifier: &str) -> Result> { - Ok(Some(DistReference { - dist_type: "zip".to_string(), - url: format!( - "https://bitbucket.org/{}/{}/get/{}.zip", - self.owner, self.repo, identifier, - ), - reference: identifier.to_string(), - shasum: None, - })) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: self.vcs_type.clone(), - url: format!("https://bitbucket.org/{}/{}.git", self.owner, self.repo), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - if let Some(driver) = &mut self.git_driver { - driver.cleanup().await?; - } - Ok(()) - } -} diff --git a/crates/mozart-core/src/vcs/driver/forgejo.rs b/crates/mozart-core/src/vcs/driver/forgejo.rs deleted file mode 100644 index 8a290c0..0000000 --- a/crates/mozart-core/src/vcs/driver/forgejo.rs +++ /dev/null @@ -1,285 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use anyhow::{Result, bail}; -use regex::Regex; -use reqwest::Client; -use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; - -use super::git::GitDriver; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// Forgejo/Gitea VCS driver using the REST API v1. -/// -/// Supports self-hosted instances (Codeberg, etc.). -pub struct ForgejoDriver { - owner: String, - repo: String, - host: String, - scheme: String, - url: String, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - git_driver: Option>, - http_client: Client, - config: DriverConfig, - api_failed: bool, -} - -impl ForgejoDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default(); - Self { - owner, - repo, - host, - scheme, - url: url.to_string(), - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - git_driver: None, - http_client: crate::http::default_client(), - config, - api_failed: false, - } - } - - pub fn supports(url: &str, forgejo_domains: &[String]) -> bool { - let url_lower = url.to_lowercase(); - for domain in forgejo_domains { - if url_lower.contains(domain) { - return true; - } - } - false - } - - fn parse_url(url: &str) -> Option<(String, String, String, String)> { - let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") - .ok()?; - let caps = re.captures(url)?; - Some(( - caps[2].to_string(), - caps[1].to_string(), - caps[3].to_string(), - caps[4].to_string(), - )) - } - - fn api_url(&self, path: &str) -> String { - format!( - "{}://{}/api/v1/repos/{}/{}{}", - self.scheme, self.host, self.owner, self.repo, path, - ) - } - - #[tracing::instrument(skip(self))] - async fn api_get(&self, path: &str) -> Result { - let url = self.api_url(path); - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/json"); - if let Some(token) = &self.config.forgejo_token { - req = req.header(AUTHORIZATION, format!("token {token}")); - } - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "Forgejo API response"); - if !response.status().is_success() { - bail!( - "Forgejo API request to {} failed: {}", - url, - response.status() - ); - } - Ok(response.json().await?) - } - - #[tracing::instrument(skip(self))] - async fn api_get_paginated(&self, path: &str) -> Result> { - let mut items = Vec::new(); - let mut page = 1; - loop { - let sep = if path.contains('?') { "&" } else { "?" }; - let paged_path = format!("{path}{sep}limit=50&page={page}"); - let data = self.api_get(&paged_path).await?; - let batch: Vec = match data { - serde_json::Value::Array(arr) => arr, - _ => break, - }; - if batch.is_empty() { - break; - } - items.extend(batch); - page += 1; - if page > 20 { - break; - } - } - Ok(items) - } - - async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { - if self.git_driver.is_none() { - let git_url = format!( - "{}://{}/{}/{}.git", - self.scheme, self.host, self.owner, self.repo - ); - let mut driver = GitDriver::new(&git_url, self.config.clone()); - driver.initialize().await?; - self.git_driver = Some(Box::new(driver)); - } - Ok(self.git_driver.as_mut().unwrap()) - } -} - -impl VcsDriver for ForgejoDriver { - async fn initialize(&mut self) -> Result<()> { - match self.api_get("").await { - Ok(data) => { - let default_branch = data["default_branch"] - .as_str() - .unwrap_or("main") - .to_string(); - self.root_identifier = Some(default_branch); - } - Err(_) => { - self.api_failed = true; - let driver = self.use_git_fallback().await?; - self.root_identifier = Some(driver.root_identifier().to_string()); - } - } - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("main") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let branches = driver.branches().await?.clone(); - self.branches = Some(branches); - } else { - let items = self.api_get_paginated("/branches").await?; - let mut branches = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["commit"]["id"].as_str()) - { - branches.insert(name.to_string(), sha.to_string()); - } - } - self.branches = Some(branches); - } - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let tags = driver.tags().await?.clone(); - self.tags = Some(tags); - } else { - let items = self.api_get_paginated("/tags").await?; - let mut tags = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = ( - item["name"].as_str(), - item["id"].as_str().or(item["commit"]["sha"].as_str()), - ) { - tags.insert(name.to_string(), sha.to_string()); - } - } - self.tags = Some(tags); - } - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - let content = self.file_content("composer.json", identifier).await?; - let value = content.and_then(|c| serde_json::from_str(&c).ok()); - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - let path = format!("/contents/{}?ref={}", file, identifier); - match self.api_get(&path).await { - Ok(data) => { - if let Some(content) = data["content"].as_str() { - // Forgejo returns base64-encoded content - let decoded = super::github::base64_decode_content(content)?; - Ok(Some(decoded)) - } else { - Ok(None) - } - } - Err(_) => Ok(None), - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - match self.api_get(&format!("/git/commits/{identifier}")).await { - Ok(data) => Ok(data["created"].as_str().map(|s| s.to_string())), - Err(_) => Ok(None), - } - } - - async fn dist(&self, identifier: &str) -> Result> { - Ok(Some(DistReference { - dist_type: "zip".to_string(), - url: format!( - "{}://{}/{}/{}/archive/{}.zip", - self.scheme, self.host, self.owner, self.repo, identifier, - ), - reference: identifier.to_string(), - shasum: None, - })) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "git".to_string(), - url: format!( - "{}://{}/{}/{}.git", - self.scheme, self.host, self.owner, self.repo - ), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - if let Some(driver) = &mut self.git_driver { - driver.cleanup().await?; - } - Ok(()) - } -} diff --git a/crates/mozart-core/src/vcs/driver/git.rs b/crates/mozart-core/src/vcs/driver/git.rs deleted file mode 100644 index 7d6643f..0000000 --- a/crates/mozart-core/src/vcs/driver/git.rs +++ /dev/null @@ -1,275 +0,0 @@ -use super::super::process::ProcessExecutor; -use super::super::util::git::GitUtil; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; -use anyhow::Result; -use indexmap::IndexMap; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -/// Git VCS driver. -/// -/// Corresponds to Composer's `Repository\Vcs\GitDriver`. -pub struct GitDriver { - url: String, - repo_dir: Option, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - git_util: GitUtil, - is_local: bool, -} - -impl GitDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let is_local = Self::is_local_path(url); - let process = ProcessExecutor::new(); - let git_util = GitUtil::new(process, config.cache_vcs_dir.clone()); - Self { - url: url.to_string(), - repo_dir: if is_local { - Some(PathBuf::from(url)) - } else { - None - }, - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - git_util, - is_local, - } - } - - /// Check if a URL is supported by the Git driver. - pub fn supports(url: &str) -> bool { - if Self::is_local_path(url) { - return Path::new(url).join(".git").is_dir() || url.ends_with(".git"); - } - url.starts_with("git://") - || url.starts_with("git@") - || url.ends_with(".git") - || url.contains("git.") - } - - fn is_local_path(url: &str) -> bool { - !url.contains("://") && !url.starts_with("git@") && Path::new(url).exists() - } - - fn get_repo_dir(&self) -> Result<&Path> { - self.repo_dir - .as_deref() - .ok_or_else(|| anyhow::anyhow!("GitDriver not initialized")) - } - - fn parse_branches(output: &str) -> BTreeMap { - let mut branches = BTreeMap::new(); - for line in output.lines() { - let line = line.trim(); - if line.is_empty() || line.contains("HEAD detached") || line.contains("->") { - continue; - } - // Remove leading "* " for current branch - let line = line.strip_prefix("* ").unwrap_or(line); - // Format: "branch_name commit_hash ..." - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 { - branches.insert(parts[0].to_string(), parts[1].to_string()); - } - } - branches - } - - fn parse_tags(output: &str) -> BTreeMap { - let mut tags = BTreeMap::new(); - // First pass: collect dereferenced tags (^{}) - let mut dereferenced = IndexMap::new(); - for line in output.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - // Format: "commit_hash refs/tags/tag_name" or "commit_hash refs/tags/tag_name^{}" - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 { - let hash = parts[0]; - let refname = parts[1]; - if let Some(tag_name) = refname.strip_prefix("refs/tags/") - && let Some(tag_name) = tag_name.strip_suffix("^{}") - { - // Dereferenced tag - this is the actual commit - dereferenced.insert(tag_name.to_string(), hash.to_string()); - } - } - } - // Second pass: collect all tags, preferring dereferenced values - for line in output.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 { - let hash = parts[0]; - let refname = parts[1]; - if let Some(tag_name) = refname.strip_prefix("refs/tags/") { - if tag_name.ends_with("^{}") { - continue; // Skip dereferenced entries themselves - } - let resolved = dereferenced - .get(tag_name) - .cloned() - .unwrap_or_else(|| hash.to_string()); - tags.insert(tag_name.to_string(), resolved); - } - } - } - tags - } -} - -impl VcsDriver for GitDriver { - async fn initialize(&mut self) -> Result<()> { - if self.is_local { - // Local repo: use directly (or its .git subdir) - let path = Path::new(&self.url); - if path.join(".git").is_dir() { - self.repo_dir = Some(path.join(".git")); - } else { - self.repo_dir = Some(path.to_path_buf()); - } - } else { - // Remote repo: sync mirror - let mirror_dir = self.git_util.sync_mirror(&self.url)?; - self.repo_dir = Some(mirror_dir); - } - - // Determine root identifier (default branch) - let repo_dir = self.repo_dir.clone().unwrap(); - if let Ok(Some(branch)) = self.git_util.get_default_branch(&repo_dir) { - self.root_identifier = Some(branch); - } else { - // Fallback: try common branch names - let process = ProcessExecutor::new(); - for name in &["main", "master"] { - let output = - process.execute(&["git", "rev-parse", "--verify", name], Some(&repo_dir))?; - if output.status == 0 { - self.root_identifier = Some(name.to_string()); - break; - } - } - } - - if self.root_identifier.is_none() { - self.root_identifier = Some("master".to_string()); - } - - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("master") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - let repo_dir = self.get_repo_dir()?.to_path_buf(); - let process = ProcessExecutor::new(); - let output = process.execute_checked( - &["git", "branch", "--no-color", "--no-abbrev", "-v"], - Some(&repo_dir), - )?; - self.branches = Some(Self::parse_branches(&output.stdout)); - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - let repo_dir = self.get_repo_dir()?.to_path_buf(); - let process = ProcessExecutor::new(); - let output = process.execute( - &["git", "show-ref", "--tags", "--dereference"], - Some(&repo_dir), - )?; - self.tags = Some(if output.status == 0 { - Self::parse_tags(&output.stdout) - } else { - BTreeMap::new() - }); - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - - let content = self.file_content("composer.json", identifier).await?; - let value = match content { - Some(c) => serde_json::from_str(&c).ok(), - None => None, - }; - - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - let repo_dir = self.get_repo_dir()?; - let process = ProcessExecutor::new(); - let resource = format!("{identifier}:{file}"); - let output = process.execute(&["git", "show", &resource], Some(repo_dir))?; - if output.status == 0 { - Ok(Some(output.stdout)) - } else { - Ok(None) - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - let repo_dir = self.get_repo_dir()?; - let process = ProcessExecutor::new(); - let output = process.execute( - &["git", "log", "-1", "--format=%aI", identifier], - Some(repo_dir), - )?; - if output.status == 0 { - let date = output.stdout.trim().to_string(); - if date.is_empty() { - Ok(None) - } else { - Ok(Some(date)) - } - } else { - Ok(None) - } - } - - async fn dist(&self, _identifier: &str) -> Result> { - // Plain git repos don't provide dist archives - Ok(None) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "git".to_string(), - url: self.url.clone(), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - Ok(()) - } -} diff --git a/crates/mozart-core/src/vcs/driver/github.rs b/crates/mozart-core/src/vcs/driver/github.rs deleted file mode 100644 index 4df2c1c..0000000 --- a/crates/mozart-core/src/vcs/driver/github.rs +++ /dev/null @@ -1,315 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use anyhow::{Result, bail}; -use regex::Regex; -use reqwest::Client; -use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT}; - -use super::git::GitDriver; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// GitHub VCS driver using the REST API v3. -/// -/// Falls back to `GitDriver` when API access fails. -pub struct GitHubDriver { - owner: String, - repo: String, - url: String, - root_identifier: Option, - tags: Option>, - branches: Option>, - repo_data: Option, - info_cache: IndexMap>, - git_driver: Option>, - http_client: Client, - config: DriverConfig, - api_failed: bool, -} - -impl GitHubDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let (owner, repo) = Self::parse_url(url).unwrap_or_default(); - Self { - owner, - repo, - url: url.to_string(), - root_identifier: None, - tags: None, - branches: None, - repo_data: None, - info_cache: IndexMap::new(), - git_driver: None, - http_client: crate::http::default_client(), - config, - api_failed: false, - } - } - - /// Check if a URL points to GitHub. - pub fn supports(url: &str) -> bool { - let url_lower = url.to_lowercase(); - url_lower.contains("github.com") - && (url_lower.contains("github.com/") || url_lower.contains("github.com:")) - } - - fn parse_url(url: &str) -> Option<(String, String)> { - let re = Regex::new(r"github\.com[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; - let caps = re.captures(url)?; - Some((caps[1].to_string(), caps[2].to_string())) - } - - fn api_url(&self, path: &str) -> String { - format!( - "https://api.github.com/repos/{}/{}{}", - self.owner, self.repo, path - ) - } - - #[tracing::instrument(skip(self))] - async fn api_get(&self, path: &str) -> Result { - let url = self.api_url(path); - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/vnd.github.v3+json"); - - if let Some(token) = &self.config.github_token { - req = req.header(AUTHORIZATION, format!("token {token}")); - } - - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "GitHub API response"); - if !response.status().is_success() { - bail!( - "GitHub API request to {} failed with status {}", - url, - response.status() - ); - } - Ok(response.json().await?) - } - - #[tracing::instrument(skip(self))] - async fn api_get_paginated(&self, path: &str) -> Result> { - let mut items = Vec::new(); - let mut page = 1; - loop { - let separator = if path.contains('?') { "&" } else { "?" }; - let url = format!( - "https://api.github.com/repos/{}/{}{}{}per_page=100&page={}", - self.owner, self.repo, path, separator, page, - ); - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/vnd.github.v3+json"); - if let Some(token) = &self.config.github_token { - req = req.header(AUTHORIZATION, format!("token {token}")); - } - - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "GitHub API paginated response"); - if !response.status().is_success() { - bail!("GitHub API paginated request failed: {}", response.status()); - } - - let batch: Vec = response.json().await?; - if batch.is_empty() { - break; - } - items.extend(batch); - page += 1; - // Safety: limit to 10 pages (1000 items) - if page > 10 { - break; - } - } - Ok(items) - } - - async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { - if self.git_driver.is_none() { - let git_url = format!("https://github.com/{}/{}.git", self.owner, self.repo); - let mut driver = GitDriver::new(&git_url, self.config.clone()); - driver.initialize().await?; - self.git_driver = Some(Box::new(driver)); - } - Ok(self.git_driver.as_mut().unwrap()) - } -} - -impl VcsDriver for GitHubDriver { - async fn initialize(&mut self) -> Result<()> { - // Try to fetch repo data from API - match self.api_get("").await { - Ok(data) => { - let default_branch = data["default_branch"] - .as_str() - .unwrap_or("main") - .to_string(); - self.root_identifier = Some(default_branch); - self.repo_data = Some(data); - } - Err(_) => { - self.api_failed = true; - let driver = self.use_git_fallback().await?; - self.root_identifier = Some(driver.root_identifier().to_string()); - } - } - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("main") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let branches = driver.branches().await?.clone(); - self.branches = Some(branches); - } else { - let items = self.api_get_paginated("/branches").await?; - let mut branches = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["commit"]["sha"].as_str()) - { - branches.insert(name.to_string(), sha.to_string()); - } - } - self.branches = Some(branches); - } - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let tags = driver.tags().await?.clone(); - self.tags = Some(tags); - } else { - let items = self.api_get_paginated("/tags").await?; - let mut tags = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["commit"]["sha"].as_str()) - { - tags.insert(name.to_string(), sha.to_string()); - } - } - self.tags = Some(tags); - } - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - - let content = self.file_content("composer.json", identifier).await?; - let value = match content { - Some(c) => serde_json::from_str(&c).ok(), - None => None, - }; - - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - if self.api_failed { - // Can't use API, would need git fallback - // For simplicity, return None (git_driver is mutable) - return Ok(None); - } - - let path = format!("/contents/{}?ref={}", file, identifier); - match self.api_get(&path).await { - Ok(data) => { - if let Some(content) = data["content"].as_str() { - // GitHub returns base64-encoded content - let decoded = base64_decode_content(content)?; - Ok(Some(decoded)) - } else { - Ok(None) - } - } - Err(_) => Ok(None), - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - - let path = format!("/commits/{}", identifier); - match self.api_get(&path).await { - Ok(data) => { - let date = data["commit"]["committer"]["date"] - .as_str() - .map(|s| s.to_string()); - Ok(date) - } - Err(_) => Ok(None), - } - } - - async fn dist(&self, identifier: &str) -> Result> { - Ok(Some(DistReference { - dist_type: "zip".to_string(), - url: format!( - "https://api.github.com/repos/{}/{}/zipball/{}", - self.owner, self.repo, identifier, - ), - reference: identifier.to_string(), - shasum: None, - })) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "git".to_string(), - url: format!("https://github.com/{}/{}.git", self.owner, self.repo), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - if let Some(driver) = &mut self.git_driver { - driver.cleanup().await?; - } - Ok(()) - } -} - -/// Decode base64-encoded content from API responses. -/// Also used by Forgejo driver as `base64_decode_content`. -pub fn base64_decode_content(input: &str) -> Result { - use base64::Engine as _; - let cleaned: Vec = input - .bytes() - .filter(|&b| b != b'\n' && b != b'\r') - .collect(); - let decoded = base64::engine::general_purpose::STANDARD - .decode(&cleaned) - .map_err(|e| anyhow::anyhow!("Base64 decode error: {e}"))?; - String::from_utf8(decoded).map_err(|e| anyhow::anyhow!("Invalid UTF-8 in base64 content: {e}")) -} diff --git a/crates/mozart-core/src/vcs/driver/gitlab.rs b/crates/mozart-core/src/vcs/driver/gitlab.rs deleted file mode 100644 index f181e63..0000000 --- a/crates/mozart-core/src/vcs/driver/gitlab.rs +++ /dev/null @@ -1,301 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use anyhow::{Result, bail}; -use regex::Regex; -use reqwest::Client; -use reqwest::header::{ACCEPT, USER_AGENT}; - -use super::git::GitDriver; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// GitLab VCS driver using the REST API v4. -/// -/// Supports self-hosted GitLab instances. -pub struct GitLabDriver { - owner: String, - repo: String, - host: String, - scheme: String, - url: String, - project_id: Option, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - git_driver: Option>, - http_client: Client, - config: DriverConfig, - api_failed: bool, -} - -impl GitLabDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let (host, scheme, owner, repo) = Self::parse_url(url).unwrap_or_default(); - Self { - owner, - repo, - host, - scheme, - url: url.to_string(), - project_id: None, - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - git_driver: None, - http_client: crate::http::default_client(), - config, - api_failed: false, - } - } - - pub fn supports(url: &str, gitlab_domains: &[String]) -> bool { - let url_lower = url.to_lowercase(); - for domain in gitlab_domains { - if url_lower.contains(domain) { - return true; - } - } - false - } - - fn parse_url(url: &str) -> Option<(String, String, String, String)> { - let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") - .ok()?; - let caps = re.captures(url)?; - Some(( - caps[2].to_string(), - caps[1].to_string(), - caps[3].to_string(), - caps[4].to_string(), - )) - } - - fn api_url(&self, path: &str) -> String { - let project_path = format!("{}%2F{}", self.owner, self.repo); - let id = self.project_id.as_deref().unwrap_or(&project_path); - format!( - "{}://{}/api/v4/projects/{}{}", - self.scheme, self.host, id, path - ) - } - - #[tracing::instrument(skip(self))] - async fn api_get(&self, path: &str) -> Result { - let url = self.api_url(path); - let mut req = self - .http_client - .get(&url) - .header(USER_AGENT, "mozart/0.1") - .header(ACCEPT, "application/json"); - - if let Some(token) = &self.config.gitlab_token { - req = req.header("PRIVATE-TOKEN", token.as_str()); - } - - let response = req.send().await?; - tracing::debug!(status = %response.status(), %url, "GitLab API response"); - if !response.status().is_success() { - bail!( - "GitLab API request to {} failed with status {}", - url, - response.status() - ); - } - Ok(response.json().await?) - } - - #[tracing::instrument(skip(self))] - async fn api_get_paginated(&self, path: &str) -> Result> { - let mut items = Vec::new(); - let mut page = 1; - loop { - let sep = if path.contains('?') { "&" } else { "?" }; - let paged_path = format!("{path}{sep}per_page=100&page={page}"); - let data = self.api_get(&paged_path).await?; - let batch: Vec = match data { - serde_json::Value::Array(arr) => arr, - _ => break, - }; - if batch.is_empty() { - break; - } - items.extend(batch); - page += 1; - if page > 10 { - break; - } - } - Ok(items) - } - - async fn use_git_fallback(&mut self) -> Result<&mut GitDriver> { - if self.git_driver.is_none() { - let git_url = format!( - "{}://{}/{}/{}.git", - self.scheme, self.host, self.owner, self.repo - ); - let mut driver = GitDriver::new(&git_url, self.config.clone()); - driver.initialize().await?; - self.git_driver = Some(Box::new(driver)); - } - Ok(self.git_driver.as_mut().unwrap()) - } -} - -impl VcsDriver for GitLabDriver { - async fn initialize(&mut self) -> Result<()> { - match self.api_get("").await { - Ok(data) => { - if let Some(id) = data["id"].as_u64() { - self.project_id = Some(id.to_string()); - } - let default_branch = data["default_branch"] - .as_str() - .unwrap_or("main") - .to_string(); - self.root_identifier = Some(default_branch); - } - Err(_) => { - self.api_failed = true; - let driver = self.use_git_fallback().await?; - self.root_identifier = Some(driver.root_identifier().to_string()); - } - } - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("main") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let branches = driver.branches().await?.clone(); - self.branches = Some(branches); - } else { - let items = self.api_get_paginated("/repository/branches").await?; - let mut branches = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["commit"]["id"].as_str()) - { - branches.insert(name.to_string(), sha.to_string()); - } - } - self.branches = Some(branches); - } - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - if self.api_failed { - let driver = self.use_git_fallback().await?; - let tags = driver.tags().await?.clone(); - self.tags = Some(tags); - } else { - let items = self.api_get_paginated("/repository/tags").await?; - let mut tags = BTreeMap::new(); - for item in items { - if let (Some(name), Some(sha)) = - (item["name"].as_str(), item["commit"]["id"].as_str()) - { - tags.insert(name.to_string(), sha.to_string()); - } - } - self.tags = Some(tags); - } - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - let content = self.file_content("composer.json", identifier).await?; - let value = content.and_then(|c| serde_json::from_str(&c).ok()); - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - let encoded_file = file.replace('/', "%2F"); - let path = format!("/repository/files/{}/raw?ref={}", encoded_file, identifier); - let url = self.api_url(&path); - let mut req = self.http_client.get(&url).header(USER_AGENT, "mozart/0.1"); - if let Some(token) = &self.config.gitlab_token { - req = req.header("PRIVATE-TOKEN", token.as_str()); - } - let response = req.send().await?; - if response.status().is_success() { - Ok(Some(response.text().await?)) - } else { - Ok(None) - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - if self.api_failed { - return Ok(None); - } - match self - .api_get(&format!("/repository/commits/{identifier}")) - .await - { - Ok(data) => Ok(data["committed_date"].as_str().map(|s| s.to_string())), - Err(_) => Ok(None), - } - } - - async fn dist(&self, identifier: &str) -> Result> { - Ok(Some(DistReference { - dist_type: "zip".to_string(), - url: format!( - "{}://{}/api/v4/projects/{}/repository/archive.zip?sha={}", - self.scheme, - self.host, - self.project_id - .as_deref() - .unwrap_or(&format!("{}%2F{}", self.owner, self.repo)), - identifier, - ), - reference: identifier.to_string(), - shasum: None, - })) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "git".to_string(), - url: format!( - "{}://{}/{}/{}.git", - self.scheme, self.host, self.owner, self.repo - ), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - if let Some(driver) = &mut self.git_driver { - driver.cleanup().await?; - } - Ok(()) - } -} diff --git a/crates/mozart-core/src/vcs/driver/hg.rs b/crates/mozart-core/src/vcs/driver/hg.rs deleted file mode 100644 index e2c3fcd..0000000 --- a/crates/mozart-core/src/vcs/driver/hg.rs +++ /dev/null @@ -1,202 +0,0 @@ -use super::super::process::ProcessExecutor; -use super::super::util::hg::HgUtil; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; -use anyhow::Result; -use indexmap::IndexMap; -use std::collections::BTreeMap; -use std::path::PathBuf; - -/// Mercurial VCS driver. -/// -/// Corresponds to Composer's `Repository\Vcs\HgDriver`. -pub struct HgDriver { - url: String, - repo_dir: Option, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - hg_util: HgUtil, - config: DriverConfig, -} - -impl HgDriver { - pub fn new(url: &str, config: DriverConfig) -> Self { - let process = ProcessExecutor::new(); - Self { - url: url.to_string(), - repo_dir: None, - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - hg_util: HgUtil::new(process), - config, - } - } - - pub fn supports(url: &str) -> bool { - url.starts_with("hg://") || url.contains("hg.") || url.ends_with(".hg") - } - - fn get_repo_dir(&self) -> Result<&PathBuf> { - self.repo_dir - .as_ref() - .ok_or_else(|| anyhow::anyhow!("HgDriver not initialized")) - } -} - -impl VcsDriver for HgDriver { - async fn initialize(&mut self) -> Result<()> { - let cache_dir = &self.config.cache_vcs_dir; - std::fs::create_dir_all(cache_dir)?; - let repo_dir = cache_dir.join(super::super::util::git::GitUtil::sanitize_url(&self.url)); - - if repo_dir.join(".hg").is_dir() { - // Update existing clone - self.hg_util.execute(&["pull"], Some(&repo_dir))?; - } else { - // Clone without checkout - let dir_str = repo_dir.to_string_lossy().to_string(); - self.hg_util - .execute(&["clone", "--noupdate", &self.url, &dir_str], None)?; - } - - self.repo_dir = Some(repo_dir.clone()); - - // Get default branch - let output = self.hg_util.execute( - &["log", "-r", "default", "--template", "{node|short}"], - Some(&repo_dir), - ); - self.root_identifier = match output { - Ok(o) if !o.stdout.trim().is_empty() => Some("default".to_string()), - _ => Some("tip".to_string()), - }; - - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("default") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - let repo_dir = self.get_repo_dir()?.clone(); - let mut branches = BTreeMap::new(); - - // Named branches - let output = self.hg_util.execute(&["branches", "-q"], Some(&repo_dir))?; - for name in ProcessExecutor::split_lines(&output.stdout) { - let name = name.trim(); - let rev_output = self.hg_util.execute( - &["log", "-r", name, "--template", "{node}"], - Some(&repo_dir), - )?; - branches.insert(name.to_string(), rev_output.stdout.trim().to_string()); - } - - // Bookmarks - let output = self - .hg_util - .execute_unchecked(&["bookmarks", "-q"], Some(&repo_dir))?; - if output.status == 0 { - for name in ProcessExecutor::split_lines(&output.stdout) { - let name = name.trim(); - if !branches.contains_key(name) { - let rev_output = self.hg_util.execute( - &["log", "-r", name, "--template", "{node}"], - Some(&repo_dir), - )?; - branches.insert(name.to_string(), rev_output.stdout.trim().to_string()); - } - } - } - - self.branches = Some(branches); - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - let repo_dir = self.get_repo_dir()?.clone(); - let output = self.hg_util.execute(&["tags", "-q"], Some(&repo_dir))?; - let mut tags = BTreeMap::new(); - for name in ProcessExecutor::split_lines(&output.stdout) { - let name = name.trim(); - if name == "tip" { - continue; // Skip the "tip" pseudo-tag - } - let rev_output = self.hg_util.execute( - &["log", "-r", name, "--template", "{node}"], - Some(&repo_dir), - )?; - tags.insert(name.to_string(), rev_output.stdout.trim().to_string()); - } - self.tags = Some(tags); - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - let content = self.file_content("composer.json", identifier).await?; - let value = content.and_then(|c| serde_json::from_str(&c).ok()); - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - let repo_dir = self.get_repo_dir()?; - let output = self - .hg_util - .execute_unchecked(&["cat", "-r", identifier, "--", file], Some(repo_dir))?; - if output.status == 0 { - Ok(Some(output.stdout)) - } else { - Ok(None) - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - let repo_dir = self.get_repo_dir()?; - let output = self.hg_util.execute( - &["log", "-r", identifier, "--template", "{date|isodatesec}"], - Some(repo_dir), - )?; - let date = output.stdout.trim().to_string(); - if date.is_empty() { - Ok(None) - } else { - Ok(Some(date)) - } - } - - async fn dist(&self, _identifier: &str) -> Result> { - Ok(None) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "hg".to_string(), - url: self.url.clone(), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - Ok(()) - } -} diff --git a/crates/mozart-core/src/vcs/driver/mod.rs b/crates/mozart-core/src/vcs/driver/mod.rs deleted file mode 100644 index cfaf11e..0000000 --- a/crates/mozart-core/src/vcs/driver/mod.rs +++ /dev/null @@ -1,309 +0,0 @@ -pub mod bitbucket; -pub mod forgejo; -pub mod git; -pub mod github; -pub mod gitlab; -pub mod hg; -pub mod svn; - -use std::collections::BTreeMap; -use std::path::PathBuf; - -use anyhow::Result; -use serde::{Deserialize, Serialize}; - -/// Reference to a source distribution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceReference { - #[serde(rename = "type")] - pub source_type: String, - pub url: String, - pub reference: String, -} - -/// Reference to a dist (archive) distribution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DistReference { - #[serde(rename = "type")] - pub dist_type: String, - pub url: String, - pub reference: String, - pub shasum: Option, -} - -/// Configuration passed to VCS drivers. -#[derive(Debug, Clone)] -pub struct DriverConfig { - /// Composer's `cache-vcs-dir`: root for VCS mirrors, one - /// subdirectory per sanitized repository URL. - pub cache_vcs_dir: PathBuf, - /// GitHub OAuth token (from `GITHUB_TOKEN` or config). - pub github_token: Option, - /// GitLab OAuth token. - pub gitlab_token: Option, - /// Bitbucket OAuth consumer key/secret. - pub bitbucket_oauth: Option<(String, String)>, - /// Forgejo token. - pub forgejo_token: Option, - /// Custom GitLab domains (for self-hosted). - pub gitlab_domains: Vec, - /// Custom Forgejo domains (for self-hosted). - pub forgejo_domains: Vec, -} - -impl Default for DriverConfig { - fn default() -> Self { - Self { - cache_vcs_dir: default_cache_vcs_dir(), - github_token: None, - gitlab_token: None, - bitbucket_oauth: None, - forgejo_token: None, - gitlab_domains: vec!["gitlab.com".to_string()], - forgejo_domains: vec!["codeberg.org".to_string()], - } - } -} - -/// Resolve the default `cache-vcs-dir`, honoring Composer's env vars. -/// -/// Priority: `COMPOSER_CACHE_VCS_DIR` → `COMPOSER_CACHE_DIR/vcs` → -/// `XDG_CACHE_HOME/mozart/vcs` → `$HOME/.cache/mozart/vcs`. -fn default_cache_vcs_dir() -> PathBuf { - if let Ok(p) = std::env::var("COMPOSER_CACHE_VCS_DIR") { - return PathBuf::from(p); - } - let base = if let Ok(p) = std::env::var("COMPOSER_CACHE_DIR") { - PathBuf::from(p) - } else if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { - PathBuf::from(xdg).join("mozart") - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home).join(".cache").join("mozart") - } else { - PathBuf::from("/tmp").join("mozart") - }; - base.join("vcs") -} - -/// Type of VCS driver. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DriverType { - GitHub, - GitLab, - Bitbucket, - Forgejo, - Git, - Svn, - Hg, -} - -/// The VCS driver interface. -/// -/// Corresponds to Composer's `VcsDriverInterface`. -trait VcsDriver { - /// Initialize the driver (e.g., clone mirror, fetch API metadata). - async fn initialize(&mut self) -> Result<()>; - - /// The root identifier (default branch/trunk). - fn root_identifier(&self) -> &str; - - /// All branches as `name -> commit_hash`. - async fn branches(&mut self) -> Result<&BTreeMap>; - - /// All tags as `name -> commit_hash`. - async fn tags(&mut self) -> Result<&BTreeMap>; - - /// Get composer.json content parsed as JSON for a given identifier. - async fn composer_information(&mut self, identifier: &str) - -> Result>; - - /// Get raw file content at a given path and identifier. - async fn file_content(&self, file: &str, identifier: &str) -> Result>; - - /// Get the change date for a given identifier (ISO 8601). - async fn change_date(&self, identifier: &str) -> Result>; - - /// Get the dist reference for a given identifier. - async fn dist(&self, identifier: &str) -> Result>; - - /// Get the source reference for a given identifier. - fn source(&self, identifier: &str) -> SourceReference; - - /// The canonical URL of this repository. - fn url(&self) -> &str; - - /// Clean up resources (temp dirs, etc.). - async fn cleanup(&mut self) -> Result<()>; -} - -/// Enum-dispatched VCS driver. -/// -/// Wraps all concrete driver types to allow static dispatch with async trait methods. -pub enum AnyVcsDriver { - GitHub(github::GitHubDriver), - GitLab(gitlab::GitLabDriver), - Bitbucket(bitbucket::BitbucketDriver), - Forgejo(forgejo::ForgejoDriver), - Git(git::GitDriver), - Svn(svn::SvnDriver), - Hg(hg::HgDriver), -} - -macro_rules! dispatch { - ($self:expr, $method:ident $(, $arg:expr)*) => { - match $self { - AnyVcsDriver::GitHub(d) => d.$method($($arg),*), - AnyVcsDriver::GitLab(d) => d.$method($($arg),*), - AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*), - AnyVcsDriver::Forgejo(d) => d.$method($($arg),*), - AnyVcsDriver::Git(d) => d.$method($($arg),*), - AnyVcsDriver::Svn(d) => d.$method($($arg),*), - AnyVcsDriver::Hg(d) => d.$method($($arg),*), - } - }; -} - -macro_rules! dispatch_async { - ($self:expr, $method:ident $(, $arg:expr)*) => { - match $self { - AnyVcsDriver::GitHub(d) => d.$method($($arg),*).await, - AnyVcsDriver::GitLab(d) => d.$method($($arg),*).await, - AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*).await, - AnyVcsDriver::Forgejo(d) => d.$method($($arg),*).await, - AnyVcsDriver::Git(d) => d.$method($($arg),*).await, - AnyVcsDriver::Svn(d) => d.$method($($arg),*).await, - AnyVcsDriver::Hg(d) => d.$method($($arg),*).await, - } - }; -} - -impl AnyVcsDriver { - pub async fn initialize(&mut self) -> Result<()> { - dispatch_async!(self, initialize) - } - - pub fn root_identifier(&self) -> &str { - dispatch!(self, root_identifier) - } - - pub async fn branches(&mut self) -> Result<&BTreeMap> { - dispatch_async!(self, branches) - } - - pub async fn tags(&mut self) -> Result<&BTreeMap> { - dispatch_async!(self, tags) - } - - pub async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - dispatch_async!(self, composer_information, identifier) - } - - pub async fn file_content(&self, file: &str, identifier: &str) -> Result> { - dispatch_async!(self, file_content, file, identifier) - } - - pub async fn change_date(&self, identifier: &str) -> Result> { - dispatch_async!(self, change_date, identifier) - } - - pub async fn dist(&self, identifier: &str) -> Result> { - dispatch_async!(self, dist, identifier) - } - - pub fn source(&self, identifier: &str) -> SourceReference { - dispatch!(self, source, identifier) - } - - pub fn url(&self) -> &str { - dispatch!(self, url) - } - - pub async fn cleanup(&mut self) -> Result<()> { - dispatch_async!(self, cleanup) - } -} - -/// Detect which driver type should handle a given URL. -/// -/// Priority order matches Composer: -/// 1. GitHub → 2. GitLab → 3. Bitbucket → 4. Forgejo → 5. Git → 6. Hg → 7. SVN -pub fn detect_driver( - url: &str, - forced_type: Option<&str>, - config: &DriverConfig, -) -> Option { - if let Some(t) = forced_type { - return match t { - "github" => Some(DriverType::GitHub), - "gitlab" => Some(DriverType::GitLab), - "bitbucket" => Some(DriverType::Bitbucket), - "forgejo" => Some(DriverType::Forgejo), - "git" => Some(DriverType::Git), - "svn" => Some(DriverType::Svn), - "hg" | "mercurial" => Some(DriverType::Hg), - _ => None, - }; - } - - let url_lower = url.to_lowercase(); - - // GitHub - if github::GitHubDriver::supports(url) { - return Some(DriverType::GitHub); - } - - // GitLab - if gitlab::GitLabDriver::supports(url, &config.gitlab_domains) { - return Some(DriverType::GitLab); - } - - // Bitbucket - if bitbucket::BitbucketDriver::supports(url) { - return Some(DriverType::Bitbucket); - } - - // Forgejo - if forgejo::ForgejoDriver::supports(url, &config.forgejo_domains) { - return Some(DriverType::Forgejo); - } - - // Git - if git::GitDriver::supports(url) { - return Some(DriverType::Git); - } - - // Hg - if hg::HgDriver::supports(url) { - return Some(DriverType::Hg); - } - - // SVN - if url_lower.contains("svn") || svn::SvnDriver::supports(url) { - return Some(DriverType::Svn); - } - - // Default to git for generic URLs - if url.starts_with("http://") || url.starts_with("https://") { - return Some(DriverType::Git); - } - - None -} - -/// Create a driver instance for the given URL and type. -pub fn create_driver(url: &str, driver_type: DriverType, config: DriverConfig) -> AnyVcsDriver { - match driver_type { - DriverType::GitHub => AnyVcsDriver::GitHub(github::GitHubDriver::new(url, config)), - DriverType::GitLab => AnyVcsDriver::GitLab(gitlab::GitLabDriver::new(url, config)), - DriverType::Bitbucket => { - AnyVcsDriver::Bitbucket(bitbucket::BitbucketDriver::new(url, config)) - } - DriverType::Forgejo => AnyVcsDriver::Forgejo(forgejo::ForgejoDriver::new(url, config)), - DriverType::Git => AnyVcsDriver::Git(git::GitDriver::new(url, config)), - DriverType::Svn => AnyVcsDriver::Svn(svn::SvnDriver::new(url, config)), - DriverType::Hg => AnyVcsDriver::Hg(hg::HgDriver::new(url, config)), - } -} diff --git a/crates/mozart-core/src/vcs/driver/svn.rs b/crates/mozart-core/src/vcs/driver/svn.rs deleted file mode 100644 index 7ba9e86..0000000 --- a/crates/mozart-core/src/vcs/driver/svn.rs +++ /dev/null @@ -1,214 +0,0 @@ -use super::super::process::ProcessExecutor; -use super::super::util::svn::SvnUtil; -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; -use anyhow::Result; -use indexmap::IndexMap; -use regex::Regex; -use std::collections::BTreeMap; - -/// SVN VCS driver. -/// -/// Corresponds to Composer's `Repository\Vcs\SvnDriver`. -pub struct SvnDriver { - url: String, - base_url: String, - trunk_path: String, - branches_path: String, - tags_path: String, - root_identifier: Option, - tags: Option>, - branches: Option>, - info_cache: IndexMap>, - svn_util: SvnUtil, -} - -impl SvnDriver { - pub fn new(url: &str, _config: DriverConfig) -> Self { - let process = ProcessExecutor::new(); - Self { - url: url.to_string(), - base_url: url.to_string(), - trunk_path: "trunk".to_string(), - branches_path: "branches".to_string(), - tags_path: "tags".to_string(), - root_identifier: None, - tags: None, - branches: None, - info_cache: IndexMap::new(), - svn_util: SvnUtil::new(process), - } - } - - pub fn supports(url: &str) -> bool { - url.starts_with("svn://") || url.starts_with("svn+ssh://") - } - - fn svn_info(&self, url: &str) -> Result { - let output = self.svn_util.execute(&["info", "--xml", url], None)?; - // Parse minimal info from XML output - let stdout = &output.stdout; - let mut info = serde_json::Map::new(); - - if let Some(rev) = extract_xml_attr(stdout, "entry", "revision") { - info.insert("revision".to_string(), serde_json::Value::String(rev)); - } - if let Some(url_val) = extract_xml_content(stdout, "url") { - info.insert("url".to_string(), serde_json::Value::String(url_val)); - } - if let Some(date) = extract_xml_content(stdout, "date") { - info.insert("date".to_string(), serde_json::Value::String(date)); - } - - Ok(serde_json::Value::Object(info)) - } - - fn svn_ls(&self, url: &str) -> Result> { - let output = self.svn_util.execute(&["ls", url], None)?; - Ok(ProcessExecutor::split_lines(&output.stdout) - .into_iter() - .map(|s| s.trim_end_matches('/').to_string()) - .collect()) - } -} - -impl VcsDriver for SvnDriver { - async fn initialize(&mut self) -> Result<()> { - let info = self.svn_info(&self.url)?; - if let Some(url) = info["url"].as_str() { - self.base_url = url.to_string(); - } - self.root_identifier = info["revision"].as_str().map(|s| s.to_string()); - Ok(()) - } - - fn root_identifier(&self) -> &str { - self.root_identifier.as_deref().unwrap_or("HEAD") - } - - async fn branches(&mut self) -> Result<&BTreeMap> { - if self.branches.is_none() { - let mut branches = BTreeMap::new(); - - // Add trunk - let trunk_url = format!("{}/{}", self.base_url, self.trunk_path); - if let Ok(info) = self.svn_info(&trunk_url) - && let Some(rev) = info["revision"].as_str() - { - branches.insert("trunk".to_string(), rev.to_string()); - } - - // List branches directory - let branches_url = format!("{}/{}", self.base_url, self.branches_path); - if let Ok(items) = self.svn_ls(&branches_url) { - for name in items { - let branch_url = format!("{}/{}", branches_url, name); - if let Ok(info) = self.svn_info(&branch_url) - && let Some(rev) = info["revision"].as_str() - { - branches.insert(name, rev.to_string()); - } - } - } - - self.branches = Some(branches); - } - Ok(self.branches.as_ref().unwrap()) - } - - async fn tags(&mut self) -> Result<&BTreeMap> { - if self.tags.is_none() { - let mut tags = BTreeMap::new(); - let tags_url = format!("{}/{}", self.base_url, self.tags_path); - if let Ok(items) = self.svn_ls(&tags_url) { - for name in items { - let tag_url = format!("{}/{}", tags_url, name); - if let Ok(info) = self.svn_info(&tag_url) - && let Some(rev) = info["revision"].as_str() - { - tags.insert(name, rev.to_string()); - } - } - } - self.tags = Some(tags); - } - Ok(self.tags.as_ref().unwrap()) - } - - async fn composer_information( - &mut self, - identifier: &str, - ) -> Result> { - if let Some(cached) = self.info_cache.get(identifier) { - return Ok(cached.clone()); - } - let content = self.file_content("composer.json", identifier).await?; - let value = content.and_then(|c| serde_json::from_str(&c).ok()); - self.info_cache - .insert(identifier.to_string(), value.clone()); - Ok(value) - } - - async fn file_content(&self, file: &str, identifier: &str) -> Result> { - // identifier is either a path (trunk, branches/x, tags/y) or a revision number - let url = if identifier.contains('/') || identifier == "trunk" { - format!("{}/{}/{}", self.base_url, identifier, file) - } else { - format!( - "{}/{}/{}@{}", - self.base_url, self.trunk_path, file, identifier - ) - }; - let output = self.svn_util.execute(&["cat", &url], None); - match output { - Ok(o) if !o.stdout.is_empty() => Ok(Some(o.stdout)), - _ => Ok(None), - } - } - - async fn change_date(&self, identifier: &str) -> Result> { - let url = if identifier.contains('/') || identifier == "trunk" { - format!("{}/{}", self.base_url, identifier) - } else { - format!("{}@{}", self.base_url, identifier) - }; - match self.svn_info(&url) { - Ok(info) => Ok(info["date"].as_str().map(|s| s.to_string())), - Err(_) => Ok(None), - } - } - - async fn dist(&self, _identifier: &str) -> Result> { - // SVN doesn't provide dist archives - Ok(None) - } - - fn source(&self, identifier: &str) -> SourceReference { - SourceReference { - source_type: "svn".to_string(), - url: self.base_url.clone(), - reference: identifier.to_string(), - } - } - - fn url(&self) -> &str { - &self.url - } - - async fn cleanup(&mut self) -> Result<()> { - Ok(()) - } -} - -/// Extract an XML attribute value from a simple XML string. -fn extract_xml_attr(xml: &str, tag: &str, attr: &str) -> Option { - let pattern = format!(r#"<{tag}\s[^>]*{attr}="([^"]*)"#); - let re = Regex::new(&pattern).ok()?; - re.captures(xml).map(|c| c[1].to_string()) -} - -/// Extract text content between XML tags. -fn extract_xml_content(xml: &str, tag: &str) -> Option { - let pattern = format!(r"<{tag}>([^<]*)"); - let re = Regex::new(&pattern).ok()?; - re.captures(xml).map(|c| c[1].to_string()) -} diff --git a/crates/mozart-core/src/vcs/repository.rs b/crates/mozart-core/src/vcs/repository.rs index 55f98f9..8da847a 100644 --- a/crates/mozart-core/src/vcs/repository.rs +++ b/crates/mozart-core/src/vcs/repository.rs @@ -1,4 +1,4 @@ -use super::driver::{ +use crate::repository::vcs::{ DistReference, DriverConfig, DriverType, SourceReference, create_driver, detect_driver, }; use anyhow::{Result, bail}; -- cgit v1.3.1