diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-10 00:32:08 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-10 00:32:08 +0900 |
| commit | 8cc1ba8a02c0318b65658f1634de378c780392b9 (patch) | |
| tree | fdd5cb61e488018891a486b25991b87c84220bb8 /crates/mozart-vcs | |
| parent | 72b2e877c01e67ba7edd37e34ac2eadb7a1c62c4 (diff) | |
| download | php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.gz php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.zst php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.zip | |
refactor(workspace): consolidate crates into mozart-core
Merged mozart-archiver, mozart-autoload, mozart-registry,
mozart-sat-resolver, and mozart-vcs into mozart-core to align
the source layout with Composer's structure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-vcs')
22 files changed, 0 insertions, 4449 deletions
diff --git a/crates/mozart-vcs/Cargo.toml b/crates/mozart-vcs/Cargo.toml deleted file mode 100644 index 92b3e24..0000000 --- a/crates/mozart-vcs/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "mozart-vcs" -version.workspace = true -edition.workspace = true - -[dependencies] -mozart-core.workspace = true -mozart-semver.workspace = true -anyhow.workspace = true -base64.workspace = true -indexmap.workspace = true -regex.workspace = true -reqwest.workspace = true -serde.workspace = true -serde_json.workspace = true -tokio.workspace = true -tracing.workspace = true -url.workspace = true - -[dev-dependencies] -tempfile.workspace = true diff --git a/crates/mozart-vcs/src/downloader/git.rs b/crates/mozart-vcs/src/downloader/git.rs deleted file mode 100644 index 814d67e..0000000 --- a/crates/mozart-vcs/src/downloader/git.rs +++ /dev/null @@ -1,274 +0,0 @@ -use std::path::Path; -use std::sync::LazyLock; - -use anyhow::Result; -use regex::Regex; - -use crate::process::ProcessExecutor; -use crate::util::git::GitUtil; - -use super::VcsDownloader; - -/// Match `<hex> HEAD` lines in `git show-ref --head -d` output. -static HEAD_REF_RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"(?im)^([a-f0-9]+) HEAD$").unwrap()); - -/// Git downloader using clone/checkout with optional mirror cache. -/// -/// Corresponds to Composer's `Downloader\GitDownloader`. -pub struct GitDownloader { - git_util: GitUtil, -} - -impl GitDownloader { - pub fn new(git_util: GitUtil) -> Self { - Self { git_util } - } -} - -impl VcsDownloader for GitDownloader { - fn download(&self, url: &str, _reference: &str, _target: &Path) -> Result<()> { - // Pre-sync the mirror so install can use --reference - self.git_util.sync_mirror(url)?; - Ok(()) - } - - fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> { - let target_str = target.to_string_lossy(); - let mirror_path = self.git_util.mirror_path(url); - - if mirror_path.join("HEAD").exists() { - // Clone with mirror reference for efficiency - let mirror_str = mirror_path.to_string_lossy().to_string(); - self.git_util.run_command( - &[ - "git", - "clone", - "--no-checkout", - "--dissociate", - "--reference", - &mirror_str, - "--", - url, - &target_str, - ], - url, - None, - )?; - } else { - self.git_util.run_command( - &["git", "clone", "--no-checkout", "--", url, &target_str], - url, - None, - )?; - } - - // Checkout the specific reference - let process = ProcessExecutor::new(); - process.execute_checked(&["git", "checkout", reference, "--force"], Some(target))?; - - Ok(()) - } - - fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> { - let process = ProcessExecutor::new(); - - // Update remote URL - process.execute_checked( - &["git", "remote", "set-url", "origin", "--", url], - Some(target), - )?; - - // Fetch latest - self.git_util - .run_command(&["git", "fetch", "origin"], url, Some(target))?; - - // Checkout new reference - process.execute_checked(&["git", "checkout", new_ref, "--force"], Some(target))?; - - Ok(()) - } - - fn remove(&self, target: &Path) -> Result<()> { - if target.exists() { - std::fs::remove_dir_all(target)?; - } - Ok(()) - } - - fn get_local_changes(&self, target: &Path) -> Result<Option<String>> { - if !target.join(".git").exists() { - return Ok(None); - } - let process = ProcessExecutor::new(); - let output = process.execute( - &["git", "status", "--porcelain", "--untracked-files=no"], - Some(target), - )?; - let trimmed = output.stdout.trim(); - if trimmed.is_empty() { - Ok(None) - } else { - Ok(Some(trimmed.to_string())) - } - } - - fn vcs_reference(&self, target: &Path) -> Result<Option<String>> { - if !target.join(".git").exists() { - return Ok(None); - } - let process = ProcessExecutor::new(); - let output = process.execute(&["git", "rev-parse", "HEAD"], Some(target))?; - if output.status != 0 { - return Ok(None); - } - let trimmed = output.stdout.trim(); - if trimmed.is_empty() { - Ok(None) - } else { - Ok(Some(trimmed.to_string())) - } - } - - fn unpushed_changes(&self, target: &Path) -> Result<Option<String>> { - if !target.join(".git").exists() { - return Ok(None); - } - let process = ProcessExecutor::new(); - - let mut refs = match collect_show_ref(&process, target)? { - Some(r) => r, - None => return Ok(None), - }; - - let head_ref = match HEAD_REF_RE - .captures(&refs) - .and_then(|c| c.get(1)) - .map(|m| m.as_str().to_string()) - { - Some(h) => h, - None => return Ok(None), - }; - - let candidate_branches = collect_local_branches(&refs, &head_ref); - if candidate_branches.is_empty() { - // not on a branch (detached / tag) — skip - return Ok(None); - } - - let mut branch = candidate_branches[0].clone(); - let mut unpushed_changes: Option<String> = None; - let mut branch_not_found_error = false; - - for i in 0..=1 { - let mut remote_branches: Vec<String> = Vec::new(); - - for candidate in &candidate_branches { - let matches = collect_remote_branches(&refs, candidate); - if !matches.is_empty() { - branch = candidate.clone(); - remote_branches = matches; - break; - } - } - - if remote_branches.is_empty() { - unpushed_changes = Some(format!( - "Branch {branch} could not be found on any remote and appears to be unpushed" - )); - branch_not_found_error = true; - } else { - if branch_not_found_error { - unpushed_changes = None; - } - for remote_branch in &remote_branches { - let range = format!("{remote_branch}...{branch}"); - let output = process.execute_checked( - &["git", "diff", "--name-status", &range, "--"], - Some(target), - )?; - let trimmed = output.stdout.trim().to_string(); - match unpushed_changes { - None => unpushed_changes = Some(trimmed), - Some(ref existing) if trimmed.len() < existing.len() => { - unpushed_changes = Some(trimmed); - } - _ => {} - } - } - } - - if unpushed_changes.as_deref().is_some_and(|s| !s.is_empty()) && i == 0 { - let _ = process.execute(&["git", "fetch", "--all"], Some(target))?; - refs = match collect_show_ref(&process, target)? { - Some(r) => r, - None => return Ok(unpushed_changes), - }; - } - - if unpushed_changes.as_deref().is_none_or(str::is_empty) { - break; - } - } - - Ok(unpushed_changes.filter(|s| !s.is_empty())) - } - - fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String> { - let process = ProcessExecutor::new(); - let range = format!("{from}..{to}"); - let output = process.execute( - &["git", "log", &range, "--oneline", "--no-decorate"], - Some(target), - )?; - Ok(output.stdout) - } - - fn is_change_report(&self) -> bool { - true - } - - fn is_vcs_capable_downloader(&self) -> bool { - true - } - - fn is_dvcs_downloader(&self) -> bool { - true - } -} - -fn collect_show_ref(process: &ProcessExecutor, target: &Path) -> Result<Option<String>> { - let output = process.execute(&["git", "show-ref", "--head", "-d"], Some(target))?; - if output.status != 0 { - anyhow::bail!( - "Failed to execute git show-ref --head -d\n\n{}", - output.stderr.trim() - ); - } - Ok(Some(output.stdout.trim().to_string())) -} - -fn collect_local_branches(refs: &str, head_ref: &str) -> Vec<String> { - let pattern = format!(r"(?im)^{} refs/heads/(.+)$", regex::escape(head_ref)); - let re = match Regex::new(&pattern) { - Ok(r) => r, - Err(_) => return Vec::new(), - }; - re.captures_iter(refs) - .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) - .collect() -} - -fn collect_remote_branches(refs: &str, candidate: &str) -> Vec<String> { - let pattern = format!( - r"(?im)^[a-f0-9]+ refs/remotes/((?:[^/]+)/{})$", - regex::escape(candidate) - ); - let re = match Regex::new(&pattern) { - Ok(r) => r, - Err(_) => return Vec::new(), - }; - re.captures_iter(refs) - .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) - .collect() -} diff --git a/crates/mozart-vcs/src/downloader/hg.rs b/crates/mozart-vcs/src/downloader/hg.rs deleted file mode 100644 index 3230404..0000000 --- a/crates/mozart-vcs/src/downloader/hg.rs +++ /dev/null @@ -1,87 +0,0 @@ -use std::path::Path; - -use anyhow::Result; - -use crate::util::hg::HgUtil; - -use super::VcsDownloader; - -/// Mercurial downloader using clone/pull/update. -pub struct HgDownloader { - hg_util: HgUtil, -} - -impl HgDownloader { - pub fn new(hg_util: HgUtil) -> Self { - Self { hg_util } - } -} - -impl VcsDownloader for HgDownloader { - fn download(&self, _url: &str, _reference: &str, _target: &Path) -> Result<()> { - Ok(()) - } - - fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> { - let target_str = target.to_string_lossy().to_string(); - self.hg_util - .execute(&["clone", "--", url, &target_str], None)?; - self.hg_util - .execute(&["update", "-r", reference], Some(target))?; - Ok(()) - } - - fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> { - self.hg_util.execute(&["pull", url], Some(target))?; - self.hg_util - .execute(&["update", "-r", new_ref], Some(target))?; - Ok(()) - } - - fn remove(&self, target: &Path) -> Result<()> { - if target.exists() { - std::fs::remove_dir_all(target)?; - } - Ok(()) - } - - fn get_local_changes(&self, target: &Path) -> Result<Option<String>> { - if !target.join(".hg").is_dir() { - return Ok(None); - } - let output = self.hg_util.execute(&["st"], Some(target))?; - let trimmed = output.stdout.trim(); - if trimmed.is_empty() { - Ok(None) - } else { - Ok(Some(trimmed.to_string())) - } - } - - fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String> { - let range = format!("{from}:{to}"); - let output = self.hg_util.execute( - &[ - "log", - "-r", - &range, - "--template", - "{rev}:{node|short} {desc|firstline}\\n", - ], - Some(target), - )?; - Ok(output.stdout) - } - - fn is_change_report(&self) -> bool { - true - } - - fn is_vcs_capable_downloader(&self) -> bool { - true - } - - fn is_dvcs_downloader(&self) -> bool { - false - } -} diff --git a/crates/mozart-vcs/src/downloader/mod.rs b/crates/mozart-vcs/src/downloader/mod.rs deleted file mode 100644 index 352f330..0000000 --- a/crates/mozart-vcs/src/downloader/mod.rs +++ /dev/null @@ -1,56 +0,0 @@ -pub mod git; -pub mod hg; -pub mod svn; - -use std::path::Path; - -use anyhow::Result; - -/// The VCS downloader interface. -/// -/// Corresponds to Composer's `VcsDownloader` hierarchy. -pub trait VcsDownloader { - /// Prepare for installation (e.g., sync mirror cache). - fn download(&self, url: &str, reference: &str, target: &Path) -> Result<()>; - - /// Install (clone/checkout) the source to the target directory. - fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()>; - - /// Update the source at target to a new reference. - fn update(&self, url: &str, old_ref: &str, new_ref: &str, target: &Path) -> Result<()>; - - /// Remove the source from the target directory. - fn remove(&self, target: &Path) -> Result<()>; - - /// Detect local changes in the working copy. - /// Returns `None` if clean, `Some(diff)` if modified. - /// Mirrors `Composer\Downloader\ChangeReportInterface::getLocalChanges`. - fn get_local_changes(&self, target: &Path) -> Result<Option<String>>; - - /// Detect commits present locally but not on the tracking remote. - /// Returns `None` if there are no unpushed commits or the concept does - /// not apply (only `GitDownloader` implements this in Composer's - /// `DvcsDownloaderInterface`). - fn unpushed_changes(&self, _target: &Path) -> Result<Option<String>> { - Ok(None) - } - - /// Resolve the working copy's current VCS reference (e.g. commit hash). - /// Returns `None` if no reference can be determined. Mirrors - /// `Composer\Downloader\VcsCapableDownloaderInterface::getVcsReference`. - fn vcs_reference(&self, _target: &Path) -> Result<Option<String>> { - Ok(None) - } - - /// Get commit log between two references. - fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String>; - - /// instanceof ChangeReportInterface - fn is_change_report(&self) -> bool; - - /// instanceof VcsCapableDownloaderInterface - fn is_vcs_capable_downloader(&self) -> bool; - - /// instanceof DvcsDownloaderInterface - fn is_dvcs_downloader(&self) -> bool; -} diff --git a/crates/mozart-vcs/src/downloader/svn.rs b/crates/mozart-vcs/src/downloader/svn.rs deleted file mode 100644 index 87b59da..0000000 --- a/crates/mozart-vcs/src/downloader/svn.rs +++ /dev/null @@ -1,87 +0,0 @@ -use std::path::Path; -use std::sync::LazyLock; - -use anyhow::Result; -use regex::Regex; - -use crate::util::svn::SvnUtil; - -use super::VcsDownloader; - -/// Match any non-`X` status line (mirror of Composer's -/// `{^ *[^X ] +}m`). Ignores externals (`X` prefix). -static SVN_STATUS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^ *[^X ] +").unwrap()); - -/// SVN downloader using checkout/switch. -pub struct SvnDownloader { - svn_util: SvnUtil, -} - -impl SvnDownloader { - pub fn new(svn_util: SvnUtil) -> Self { - Self { svn_util } - } -} - -impl VcsDownloader for SvnDownloader { - fn download(&self, _url: &str, _reference: &str, _target: &Path) -> Result<()> { - // SVN doesn't need a pre-download step - Ok(()) - } - - fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> { - let target_str = target.to_string_lossy().to_string(); - let svn_url = format!("{url}@{reference}"); - self.svn_util - .execute(&["checkout", &svn_url, &target_str], None)?; - Ok(()) - } - - fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> { - let svn_url = format!("{url}@{new_ref}"); - self.svn_util - .execute(&["switch", "--ignore-ancestry", &svn_url], Some(target))?; - Ok(()) - } - - fn remove(&self, target: &Path) -> Result<()> { - if target.exists() { - std::fs::remove_dir_all(target)?; - } - Ok(()) - } - - fn get_local_changes(&self, target: &Path) -> Result<Option<String>> { - if !target.join(".svn").is_dir() { - return Ok(None); - } - let output = self - .svn_util - .execute(&["status", "--ignore-externals"], Some(target))?; - if SVN_STATUS_RE.is_match(&output.stdout) { - Ok(Some(output.stdout)) - } else { - Ok(None) - } - } - - fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String> { - let range = format!("{from}:{to}"); - let output = self - .svn_util - .execute(&["log", "-r", &range], Some(target))?; - Ok(output.stdout) - } - - fn is_change_report(&self) -> bool { - true - } - - fn is_vcs_capable_downloader(&self) -> bool { - true - } - - fn is_dvcs_downloader(&self) -> bool { - false - } -} diff --git a/crates/mozart-vcs/src/driver/bitbucket.rs b/crates/mozart-vcs/src/driver/bitbucket.rs deleted file mode 100644 index 0e67bc8..0000000 --- a/crates/mozart-vcs/src/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<String>, - tags: Option<BTreeMap<String, String>>, - branches: Option<BTreeMap<String, String>>, - info_cache: IndexMap<String, Option<serde_json::Value>>, - git_driver: Option<Box<GitDriver>>, - 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: mozart_core::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<serde_json::Value> { - 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<Vec<serde_json::Value>> { - 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<String, String>> { - 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<String, String>> { - 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<Option<serde_json::Value>> { - 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<Option<String>> { - 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<Option<String>> { - 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<Option<DistReference>> { - 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-vcs/src/driver/forgejo.rs b/crates/mozart-vcs/src/driver/forgejo.rs deleted file mode 100644 index 665c177..0000000 --- a/crates/mozart-vcs/src/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<String>, - tags: Option<BTreeMap<String, String>>, - branches: Option<BTreeMap<String, String>>, - info_cache: IndexMap<String, Option<serde_json::Value>>, - git_driver: Option<Box<GitDriver>>, - 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: mozart_core::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<serde_json::Value> { - 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<Vec<serde_json::Value>> { - 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<serde_json::Value> = 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<String, String>> { - 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<String, String>> { - 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<Option<serde_json::Value>> { - 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<Option<String>> { - 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<Option<String>> { - 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<Option<DistReference>> { - 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-vcs/src/driver/git.rs b/crates/mozart-vcs/src/driver/git.rs deleted file mode 100644 index 090a5fa..0000000 --- a/crates/mozart-vcs/src/driver/git.rs +++ /dev/null @@ -1,278 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -use anyhow::Result; - -use crate::process::ProcessExecutor; -use crate::util::git::GitUtil; - -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// Git VCS driver. -/// -/// Corresponds to Composer's `Repository\Vcs\GitDriver`. -pub struct GitDriver { - url: String, - repo_dir: Option<PathBuf>, - root_identifier: Option<String>, - tags: Option<BTreeMap<String, String>>, - branches: Option<BTreeMap<String, String>>, - info_cache: IndexMap<String, Option<serde_json::Value>>, - 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<String, String> { - 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<String, String> { - 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<String, String>> { - 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<String, String>> { - 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<Option<serde_json::Value>> { - 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<Option<String>> { - 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<Option<String>> { - 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<Option<DistReference>> { - // 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-vcs/src/driver/github.rs b/crates/mozart-vcs/src/driver/github.rs deleted file mode 100644 index e968c3e..0000000 --- a/crates/mozart-vcs/src/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<String>, - tags: Option<BTreeMap<String, String>>, - branches: Option<BTreeMap<String, String>>, - repo_data: Option<serde_json::Value>, - info_cache: IndexMap<String, Option<serde_json::Value>>, - git_driver: Option<Box<GitDriver>>, - 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: mozart_core::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<serde_json::Value> { - 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<Vec<serde_json::Value>> { - 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<serde_json::Value> = 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<String, String>> { - 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<String, String>> { - 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<Option<serde_json::Value>> { - 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<Option<String>> { - 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<Option<String>> { - 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<Option<DistReference>> { - 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<String> { - use base64::Engine; - let cleaned: Vec<u8> = 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-vcs/src/driver/gitlab.rs b/crates/mozart-vcs/src/driver/gitlab.rs deleted file mode 100644 index 937251a..0000000 --- a/crates/mozart-vcs/src/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<String>, - root_identifier: Option<String>, - tags: Option<BTreeMap<String, String>>, - branches: Option<BTreeMap<String, String>>, - info_cache: IndexMap<String, Option<serde_json::Value>>, - git_driver: Option<Box<GitDriver>>, - 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: mozart_core::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<serde_json::Value> { - 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<Vec<serde_json::Value>> { - 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<serde_json::Value> = 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<String, String>> { - 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<String, String>> { - 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<Option<serde_json::Value>> { - 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<Option<String>> { - 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<Option<String>> { - 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<Option<DistReference>> { - 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-vcs/src/driver/hg.rs b/crates/mozart-vcs/src/driver/hg.rs deleted file mode 100644 index f476e6a..0000000 --- a/crates/mozart-vcs/src/driver/hg.rs +++ /dev/null @@ -1,205 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; -use std::path::PathBuf; - -use anyhow::Result; - -use crate::process::ProcessExecutor; -use crate::util::hg::HgUtil; - -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// Mercurial VCS driver. -/// -/// Corresponds to Composer's `Repository\Vcs\HgDriver`. -pub struct HgDriver { - url: String, - repo_dir: Option<PathBuf>, - root_identifier: Option<String>, - tags: Option<BTreeMap<String, String>>, - branches: Option<BTreeMap<String, String>>, - info_cache: IndexMap<String, Option<serde_json::Value>>, - 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(crate::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<String, String>> { - 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<String, String>> { - 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<Option<serde_json::Value>> { - 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<Option<String>> { - 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<Option<String>> { - 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<Option<DistReference>> { - 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-vcs/src/driver/mod.rs b/crates/mozart-vcs/src/driver/mod.rs deleted file mode 100644 index cfaf11e..0000000 --- a/crates/mozart-vcs/src/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<String>, -} - -/// 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<String>, - /// GitLab OAuth token. - pub gitlab_token: Option<String>, - /// Bitbucket OAuth consumer key/secret. - pub bitbucket_oauth: Option<(String, String)>, - /// Forgejo token. - pub forgejo_token: Option<String>, - /// Custom GitLab domains (for self-hosted). - pub gitlab_domains: Vec<String>, - /// Custom Forgejo domains (for self-hosted). - pub forgejo_domains: Vec<String>, -} - -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<String, String>>; - - /// All tags as `name -> commit_hash`. - async fn tags(&mut self) -> Result<&BTreeMap<String, String>>; - - /// Get composer.json content parsed as JSON for a given identifier. - async fn composer_information(&mut self, identifier: &str) - -> Result<Option<serde_json::Value>>; - - /// Get raw file content at a given path and identifier. - async fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>>; - - /// Get the change date for a given identifier (ISO 8601). - async fn change_date(&self, identifier: &str) -> Result<Option<String>>; - - /// Get the dist reference for a given identifier. - async fn dist(&self, identifier: &str) -> Result<Option<DistReference>>; - - /// 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<String, String>> { - dispatch_async!(self, branches) - } - - pub async fn tags(&mut self) -> Result<&BTreeMap<String, String>> { - dispatch_async!(self, tags) - } - - pub async fn composer_information( - &mut self, - identifier: &str, - ) -> Result<Option<serde_json::Value>> { - dispatch_async!(self, composer_information, identifier) - } - - pub async fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> { - dispatch_async!(self, file_content, file, identifier) - } - - pub async fn change_date(&self, identifier: &str) -> Result<Option<String>> { - dispatch_async!(self, change_date, identifier) - } - - pub async fn dist(&self, identifier: &str) -> Result<Option<DistReference>> { - 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<DriverType> { - 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-vcs/src/driver/svn.rs b/crates/mozart-vcs/src/driver/svn.rs deleted file mode 100644 index 16363e1..0000000 --- a/crates/mozart-vcs/src/driver/svn.rs +++ /dev/null @@ -1,217 +0,0 @@ -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use anyhow::Result; -use regex::Regex; - -use crate::process::ProcessExecutor; -use crate::util::svn::SvnUtil; - -use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; - -/// 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<String>, - tags: Option<BTreeMap<String, String>>, - branches: Option<BTreeMap<String, String>>, - info_cache: IndexMap<String, Option<serde_json::Value>>, - 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<serde_json::Value> { - 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<Vec<String>> { - 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<String, String>> { - 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<String, String>> { - 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<Option<serde_json::Value>> { - 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<Option<String>> { - // 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<Option<String>> { - 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<Option<DistReference>> { - // 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<String> { - 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<String> { - let pattern = format!(r"<{tag}>([^<]*)</{tag}>"); - let re = Regex::new(&pattern).ok()?; - re.captures(xml).map(|c| c[1].to_string()) -} diff --git a/crates/mozart-vcs/src/lib.rs b/crates/mozart-vcs/src/lib.rs deleted file mode 100644 index e7ca383..0000000 --- a/crates/mozart-vcs/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod downloader; -pub mod driver; -pub mod process; -pub mod repository; -pub mod util; -pub mod version_guesser; diff --git a/crates/mozart-vcs/src/process.rs b/crates/mozart-vcs/src/process.rs deleted file mode 100644 index 8ccc11d..0000000 --- a/crates/mozart-vcs/src/process.rs +++ /dev/null @@ -1,142 +0,0 @@ -use indexmap::IndexMap; -use std::path::Path; -use std::process::Command; -use std::time::{Duration, Instant}; - -use anyhow::{Result, bail}; - -/// Output from a process execution. -#[derive(Debug, Clone)] -pub struct ProcessOutput { - pub status: i32, - pub stdout: String, - pub stderr: String, -} - -/// Wrapper around `std::process::Command` for executing external programs. -/// -/// Corresponds to Composer's `ProcessExecutor`. -pub struct ProcessExecutor { - timeout: Option<Duration>, - env_overrides: IndexMap<String, Option<String>>, -} - -impl Default for ProcessExecutor { - fn default() -> Self { - Self::new() - } -} - -impl ProcessExecutor { - pub fn new() -> Self { - Self { - timeout: None, - env_overrides: IndexMap::new(), - } - } - - pub fn with_timeout(secs: u64) -> Self { - Self { - timeout: Some(Duration::from_secs(secs)), - env_overrides: IndexMap::new(), - } - } - - /// Set an environment variable override for all subsequent executions. - pub fn set_env(&mut self, key: impl Into<String>, value: impl Into<String>) { - self.env_overrides.insert(key.into(), Some(value.into())); - } - - /// Remove an environment variable for all subsequent executions. - pub fn remove_env(&mut self, key: impl Into<String>) { - self.env_overrides.insert(key.into(), None); - } - - /// Execute a command. Does not error on non-zero exit status. - pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> { - if args.is_empty() { - bail!("No command specified"); - } - - let mut cmd = Command::new(args[0]); - if args.len() > 1 { - cmd.args(&args[1..]); - } - if let Some(dir) = cwd { - cmd.current_dir(dir); - } - - for (key, value) in &self.env_overrides { - match value { - Some(v) => { - cmd.env(key, v); - } - None => { - cmd.env_remove(key); - } - } - } - - if let Some(timeout) = self.timeout { - let mut child = cmd - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn()?; - - let start = Instant::now(); - loop { - match child.try_wait()? { - Some(status) => { - let mut stdout = String::new(); - let mut stderr = String::new(); - if let Some(ref mut out) = child.stdout { - std::io::Read::read_to_string(out, &mut stdout)?; - } - if let Some(ref mut err) = child.stderr { - std::io::Read::read_to_string(err, &mut stderr)?; - } - return Ok(ProcessOutput { - status: status.code().unwrap_or(-1), - stdout, - stderr, - }); - } - None => { - if start.elapsed() > timeout { - let _ = child.kill(); - bail!("Process timed out after {} seconds", timeout.as_secs()); - } - std::thread::sleep(Duration::from_millis(50)); - } - } - } - } else { - let output = cmd.output()?; - Ok(ProcessOutput { - status: output.status.code().unwrap_or(-1), - stdout: String::from_utf8_lossy(&output.stdout).into_owned(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }) - } - } - - /// Execute a command, returning an error if the exit status is non-zero. - pub fn execute_checked(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> { - let output = self.execute(args, cwd)?; - if output.status != 0 { - bail!( - "Command `{}` failed with exit code {}\nstdout: {}\nstderr: {}", - args.join(" "), - output.status, - output.stdout.trim(), - output.stderr.trim(), - ); - } - Ok(output) - } - - /// Split output into non-empty lines. - pub fn split_lines(output: &str) -> Vec<&str> { - output.lines().filter(|l| !l.is_empty()).collect() - } -} diff --git a/crates/mozart-vcs/src/repository.rs b/crates/mozart-vcs/src/repository.rs deleted file mode 100644 index b941eec..0000000 --- a/crates/mozart-vcs/src/repository.rs +++ /dev/null @@ -1,206 +0,0 @@ -use anyhow::{Result, bail}; - -use crate::driver::{ - DistReference, DriverConfig, DriverType, SourceReference, create_driver, detect_driver, -}; - -/// A single package version discovered from a VCS repository. -#[derive(Debug, Clone)] -pub struct VcsPackageVersion { - /// Package name (from composer.json). - pub name: String, - /// Version string (e.g., "1.2.3" for tags, "dev-main" for branches). - pub version: String, - /// Normalized version for comparison. - pub version_normalized: String, - /// Full composer.json data as JSON. - pub composer_json: serde_json::Value, - /// Source reference (VCS checkout info). - pub source: SourceReference, - /// Dist reference (archive download, if available). - pub dist: Option<DistReference>, - /// Whether this is the default branch version. - pub is_default_branch: bool, - /// Release date (ISO 8601). - pub time: Option<String>, -} - -/// Repository that scans a VCS URL for package versions. -/// -/// Corresponds to Composer's `Repository\VcsRepository`. -pub struct VcsRepository { - url: String, - driver_type: Option<DriverType>, - config: DriverConfig, -} - -impl VcsRepository { - pub fn new(url: String, repo_type: Option<&str>, config: DriverConfig) -> Self { - let driver_type = detect_driver(&url, repo_type, &config); - Self { - url, - driver_type, - config, - } - } - - /// Scan the VCS repository for all package versions. - /// - /// 1. Detects the driver type and initializes it - /// 2. Reads composer.json from the root to get the package name - /// 3. Scans tags → version releases - /// 4. Scans branches → dev versions - pub async fn scan(&self) -> Result<Vec<VcsPackageVersion>> { - let driver_type = self - .driver_type - .ok_or_else(|| anyhow::anyhow!("No suitable VCS driver found for URL: {}", self.url))?; - - let mut driver = create_driver(&self.url, driver_type, self.config.clone()); - driver.initialize().await?; - - // Get package name from root composer.json - let root_id = driver.root_identifier().to_string(); - let root_info = driver.composer_information(&root_id).await?; - let package_name = match &root_info { - Some(info) => info["name"] - .as_str() - .ok_or_else(|| { - anyhow::anyhow!( - "composer.json at root of {} does not contain a 'name' field", - self.url, - ) - })? - .to_string(), - None => bail!( - "No composer.json found at root of {} (ref: {})", - self.url, - root_id, - ), - }; - - let mut versions = Vec::new(); - - // Scan tags - let tags = driver.tags().await?.clone(); - for (tag_name, tag_hash) in &tags { - if let Some(version) = self.tag_to_version(tag_name) { - match driver.composer_information(tag_hash).await { - Ok(Some(info)) => { - let time = driver.change_date(tag_hash).await.unwrap_or(None); - let source = driver.source(tag_hash); - let dist = driver.dist(tag_hash).await.unwrap_or(None); - - // Ensure name matches root package - if info["name"].as_str() != Some(&package_name) { - continue; - } - - let normalized = self.normalize_version(&version); - - versions.push(VcsPackageVersion { - name: package_name.clone(), - version: version.clone(), - version_normalized: normalized, - composer_json: info, - source, - dist, - is_default_branch: false, - time, - }); - } - Ok(None) | Err(_) => continue, - } - } - } - - // Scan branches - let branches = driver.branches().await?.clone(); - let default_branch = driver.root_identifier().to_string(); - for (branch_name, branch_hash) in &branches { - match driver.composer_information(branch_hash).await { - Ok(Some(info)) => { - if info["name"].as_str() != Some(&package_name) { - continue; - } - - let time = driver.change_date(branch_hash).await.unwrap_or(None); - let source = driver.source(branch_hash); - let dist = driver.dist(branch_hash).await.unwrap_or(None); - let is_default = branch_name == &default_branch; - - let version = self.branch_to_version(branch_name); - let normalized = self.normalize_version(&version); - - // Check for branch-alias - let aliased_version = info - .get("extra") - .and_then(|e| e.get("branch-alias")) - .and_then(|ba| ba.get(format!("dev-{branch_name}"))) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - - versions.push(VcsPackageVersion { - name: package_name.clone(), - version: aliased_version.unwrap_or(version), - version_normalized: normalized, - composer_json: info, - source, - dist, - is_default_branch: is_default, - time, - }); - } - Ok(None) | Err(_) => continue, - } - } - - driver.cleanup().await?; - Ok(versions) - } - - /// Convert a tag name to a version string. - /// Returns `None` if the tag doesn't look like a version. - fn tag_to_version(&self, tag: &str) -> Option<String> { - // Strip common prefixes - let version = tag - .strip_prefix('v') - .or_else(|| tag.strip_prefix("V")) - .or_else(|| tag.strip_prefix("release-")) - .or_else(|| tag.strip_prefix("release/")) - .unwrap_or(tag); - - // Basic semver-ish check - if version.is_empty() { - return None; - } - if version.chars().next()?.is_ascii_digit() { - Some(version.to_string()) - } else { - None - } - } - - /// Convert a branch name to a dev version string. - fn branch_to_version(&self, branch: &str) -> String { - // Numeric branches like "1.x", "2.0" become "1.x-dev", "2.0.x-dev" - if branch.chars().next().is_some_and(|c| c.is_ascii_digit()) { - let version = if branch.ends_with(".x") || branch.ends_with(".*") { - branch.to_string() - } else { - format!("{branch}.x") - }; - format!("{version}-dev") - } else { - format!("dev-{branch}") - } - } - - /// Normalize a version string. - fn normalize_version(&self, version: &str) -> String { - // Use mozart-semver for proper normalization if available, - // otherwise do a simple normalization - mozart_semver::Version::parse(version) - .map(|v| v.to_string()) - .unwrap_or_else(|_| version.to_string()) - } -} diff --git a/crates/mozart-vcs/src/util/git.rs b/crates/mozart-vcs/src/util/git.rs deleted file mode 100644 index ab4366d..0000000 --- a/crates/mozart-vcs/src/util/git.rs +++ /dev/null @@ -1,314 +0,0 @@ -use std::path::{Path, PathBuf}; -use std::sync::LazyLock; - -use anyhow::{Result, bail}; -use regex::Regex; - -use crate::process::{ProcessExecutor, ProcessOutput}; - -/// Modern GitHub token pattern (40+ hex chars, `ghp_…`, `github_pat_…`). -/// -/// Mirrors `Composer\Util\GitHub::GITHUB_TOKEN_REGEX`. -static GITHUB_TOKEN_RE: LazyLock<Regex> = LazyLock::new(|| { - Regex::new(r"^([a-fA-F0-9]{12,}|gh[a-zA-Z]_[a-zA-Z0-9_]+|github_pat_[a-zA-Z0-9_]+)$").unwrap() -}); - -/// `[?&]access_token=...` query parameter. -static ACCESS_TOKEN_RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"([&?]access_token=)[^&]+").unwrap()); - -/// `<scheme>://user:password@` credential block. -static CREDENTIALS_RE: LazyLock<Regex> = LazyLock::new(|| { - Regex::new(r"(?i)(?P<prefix>[a-z0-9]+://)?(?P<user>[^:/\s@]+):(?P<password>[^@\s/]+)@").unwrap() -}); - -/// Git utility for mirror management and protocol fallback. -/// -/// Corresponds to Composer's `Util\Git`. -pub struct GitUtil { - process: ProcessExecutor, - cache_dir: PathBuf, -} - -impl GitUtil { - pub fn new(process: ProcessExecutor, cache_dir: PathBuf) -> Self { - Self { process, cache_dir } - } - - /// Returns environment variable overrides to clean Git state. - /// Removes `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE` to avoid - /// interference from the calling process's Git context. - pub fn clean_env() -> Vec<(&'static str, Option<&'static str>)> { - vec![ - ("GIT_DIR", None), - ("GIT_WORK_TREE", None), - ("GIT_INDEX_FILE", None), - ("GIT_TERMINAL_PROMPT", Some("0")), - ] - } - - /// Synchronize a bare mirror in the cache directory. - /// - /// On first call, clones a bare mirror. On subsequent calls, updates it. - /// Returns the path to the mirror directory. - pub fn sync_mirror(&self, url: &str) -> Result<PathBuf> { - let mirror_dir = self.mirror_path(url); - - if mirror_dir.join("HEAD").exists() { - // Update existing mirror - self.run_command( - &["git", "remote", "set-url", "origin", "--", url], - url, - Some(&mirror_dir), - )?; - self.run_command( - &["git", "remote", "update", "--prune", "origin"], - url, - Some(&mirror_dir), - )?; - } else { - // Create new mirror - std::fs::create_dir_all(&mirror_dir)?; - self.run_command( - &[ - "git", - "clone", - "--mirror", - "--", - url, - mirror_dir.to_str().unwrap_or(""), - ], - url, - None, - )?; - } - - Ok(mirror_dir) - } - - /// Fetch a specific refspec from the mirror. - pub fn fetch_ref(&self, mirror_dir: &Path, refspec: &str) -> Result<bool> { - let output = self - .process - .execute(&["git", "fetch", "origin", refspec], Some(mirror_dir))?; - Ok(output.status == 0) - } - - /// Get the default branch of a repository. - pub fn get_default_branch(&self, mirror_dir: &Path) -> Result<Option<String>> { - let output = self - .process - .execute(&["git", "remote", "show", "origin"], Some(mirror_dir))?; - if output.status != 0 { - return Ok(None); - } - for line in output.stdout.lines() { - let trimmed = line.trim(); - if let Some(branch) = trimmed.strip_prefix("HEAD branch:") { - let branch = branch.trim(); - if branch != "(unknown)" { - return Ok(Some(branch.to_string())); - } - } - } - Ok(None) - } - - /// Execute a git command with protocol fallback. - /// - /// Tries the URL as-is first, then falls back through protocol variations - /// (ssh → https → git://) if the command fails. - pub fn run_command( - &self, - args: &[&str], - url: &str, - cwd: Option<&Path>, - ) -> Result<ProcessOutput> { - let mut executor = ProcessExecutor::new(); - for (key, value) in Self::clean_env() { - match value { - Some(v) => executor.set_env(key, v), - None => executor.remove_env(key), - } - } - - // Try the command as-is first - let output = executor.execute(args, cwd)?; - if output.status == 0 { - return Ok(output); - } - - // Try protocol fallback for remote URLs - let fallback_urls = Self::get_fallback_urls(url); - for fallback_url in &fallback_urls { - let new_args: Vec<&str> = args - .iter() - .map(|&a| if a == url { fallback_url.as_str() } else { a }) - .collect(); - let fallback_output = executor.execute(&new_args, cwd)?; - if fallback_output.status == 0 { - return Ok(fallback_output); - } - } - - // Return the original error - if output.status != 0 { - bail!( - "Git command `{}` failed with exit code {}\nstdout: {}\nstderr: {}", - args.join(" "), - output.status, - output.stdout.trim(), - output.stderr.trim(), - ); - } - Ok(output) - } - - /// Get the Git version string. - pub fn get_version(&self) -> Option<String> { - let output = self.process.execute(&["git", "--version"], None).ok()?; - if output.status != 0 { - return None; - } - // "git version 2.39.2" -> "2.39.2" - output - .stdout - .trim() - .strip_prefix("git version ") - .map(|s| s.to_string()) - } - - /// Sanitize a URL for use as a cache directory name. - /// - /// Mirrors Composer's `Preg::replace('{[^a-z0-9.]}i', '-', Url::sanitize($url))` - /// pattern (see `GitDriver::initialize` and `GitDownloader`): credentials and - /// access tokens are first redacted, then every byte outside `[a-zA-Z0-9.]` - /// is replaced with `-`. The redaction step keeps cache keys stable across - /// URLs that differ only in their embedded token. - pub fn sanitize_url(url: &str) -> String { - let redacted = sanitize_url_credentials(url); - redacted - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '.' { - c - } else { - '-' - } - }) - .collect() - } - - /// Get the cache mirror path for a URL. - pub fn mirror_path(&self, url: &str) -> PathBuf { - self.cache_dir.join(Self::sanitize_url(url)) - } - - /// Generate fallback URLs for protocol switching. - fn get_fallback_urls(url: &str) -> Vec<String> { - let mut urls = Vec::new(); - - // ssh -> https fallback - if url.starts_with("git@") { - // git@github.com:owner/repo.git -> https://github.com/owner/repo.git - if let Some(rest) = url.strip_prefix("git@") { - let converted = rest.replacen(':', "/", 1); - urls.push(format!("https://{converted}")); - } - } - - // git:// -> https:// fallback - if let Some(rest) = url.strip_prefix("git://") { - urls.push(format!("https://{rest}")); - } - - // https -> git:// fallback - if let Some(rest) = url.strip_prefix("https://") { - urls.push(format!("git://{rest}")); - } - - urls - } -} - -/// Redact credentials and access tokens from `url`. -/// -/// Mirrors Composer's `Util\Url::sanitize`. Two replacements are applied: -/// 1. `[?&]access_token=…` query values → `***` -/// 2. `<scheme>://user:password@` credentials → `***:***@` if `user` looks like -/// a GitHub token, otherwise just `user:***@` -fn sanitize_url_credentials(url: &str) -> String { - let url = ACCESS_TOKEN_RE.replace_all(url, "${1}***"); - CREDENTIALS_RE - .replace_all(&url, |caps: ®ex::Captures<'_>| { - let prefix = caps.name("prefix").map(|m| m.as_str()).unwrap_or(""); - let user = &caps["user"]; - if GITHUB_TOKEN_RE.is_match(user) { - format!("{prefix}***:***@") - } else { - format!("{prefix}{user}:***@") - } - }) - .into_owned() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sanitize_url_replaces_special_chars_with_dash() { - assert_eq!( - GitUtil::sanitize_url("https://github.com/owner/repo.git"), - "https---github.com-owner-repo.git" - ); - } - - #[test] - fn sanitize_url_preserves_dot() { - // Dot must survive — it appears in hostnames and ".git" suffixes. - let key = GitUtil::sanitize_url("git://example.org/foo.bar/baz.git"); - assert!(key.contains(".org")); - assert!(key.ends_with(".git")); - } - - #[test] - fn sanitize_url_redacts_password_in_credentials() { - let key = GitUtil::sanitize_url("https://alice:s3cret@example.com/repo.git"); - // Password is replaced with ***, then non-alphanumerics become '-'. - assert!(key.contains("alice")); - assert!(!key.contains("s3cret")); - } - - #[test] - fn sanitize_url_redacts_user_when_looks_like_github_token() { - // 40-hex token in the user position triggers full redaction. - let token = "abcdef0123456789abcdef0123456789abcdef01"; - let key = GitUtil::sanitize_url(&format!("https://{token}:x-oauth-basic@github.com/o/r")); - assert!(!key.contains("abcdef")); - } - - #[test] - fn sanitize_url_redacts_modern_github_pat() { - // ghp_xxx and github_pat_xxx forms. - let key1 = GitUtil::sanitize_url("https://ghp_abc123XYZ:x@github.com/o/r"); - assert!(!key1.contains("ghp_")); - let key2 = GitUtil::sanitize_url("https://github_pat_abc123:x@github.com/o/r"); - assert!(!key2.contains("github_pat_")); - } - - #[test] - fn sanitize_url_strips_access_token_query() { - let key = GitUtil::sanitize_url("https://api.github.com/x?access_token=secrettoken"); - assert!(!key.contains("secrettoken")); - } - - #[test] - fn sanitize_url_token_variants_share_cache_key() { - // Two pulls of the same repo with different access tokens should land - // in the same cache subdirectory. - let a = GitUtil::sanitize_url("https://api.github.com/repo?access_token=tokenA"); - let b = GitUtil::sanitize_url("https://api.github.com/repo?access_token=tokenB"); - assert_eq!(a, b); - } -} diff --git a/crates/mozart-vcs/src/util/hg.rs b/crates/mozart-vcs/src/util/hg.rs deleted file mode 100644 index 7f5abcc..0000000 --- a/crates/mozart-vcs/src/util/hg.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::path::Path; - -use anyhow::Result; - -use crate::process::{ProcessExecutor, ProcessOutput}; - -/// Mercurial utility for command execution. -pub struct HgUtil { - process: ProcessExecutor, -} - -impl HgUtil { - pub fn new(process: ProcessExecutor) -> Self { - Self { process } - } - - /// Execute a Mercurial command. - pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> { - let mut full_args = vec!["hg"]; - full_args.extend_from_slice(args); - self.process.execute_checked(&full_args, cwd) - } - - /// Execute a Mercurial command, not erroring on non-zero exit. - pub fn execute_unchecked(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> { - let mut full_args = vec!["hg"]; - full_args.extend_from_slice(args); - self.process.execute(&full_args, cwd) - } -} diff --git a/crates/mozart-vcs/src/util/mod.rs b/crates/mozart-vcs/src/util/mod.rs deleted file mode 100644 index b2c35fc..0000000 --- a/crates/mozart-vcs/src/util/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod git; -pub mod hg; -pub mod svn; diff --git a/crates/mozart-vcs/src/util/svn.rs b/crates/mozart-vcs/src/util/svn.rs deleted file mode 100644 index e9a6813..0000000 --- a/crates/mozart-vcs/src/util/svn.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::path::Path; - -use anyhow::Result; - -use crate::process::{ProcessExecutor, ProcessOutput}; - -/// SVN credentials for authenticated operations. -#[derive(Debug, Clone)] -pub struct SvnCredentials { - pub username: String, - pub password: String, -} - -/// SVN utility for command execution with credential handling. -pub struct SvnUtil { - process: ProcessExecutor, -} - -impl SvnUtil { - pub fn new(process: ProcessExecutor) -> Self { - Self { process } - } - - /// Execute an SVN command with `--non-interactive`. - pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> { - let mut full_args = vec!["svn"]; - full_args.extend_from_slice(args); - full_args.push("--non-interactive"); - self.process.execute_checked(&full_args, cwd) - } - - /// Execute an SVN command with optional credentials, retrying on auth failure. - pub fn execute_with_credentials( - &self, - args: &[&str], - creds: Option<&SvnCredentials>, - cwd: Option<&Path>, - ) -> Result<ProcessOutput> { - let mut full_args = vec!["svn"]; - full_args.extend_from_slice(args); - full_args.push("--non-interactive"); - - let cred_args: Vec<String>; - if let Some(c) = creds { - cred_args = vec![ - "--username".to_string(), - c.username.clone(), - "--password".to_string(), - c.password.clone(), - ]; - for arg in &cred_args { - full_args.push(arg); - } - } - - let full_args_refs: Vec<&str> = full_args.iter().map(|s| &**s).collect(); - - // Retry up to 5 times on auth failure - let max_retries = 5; - let mut last_output = None; - for _ in 0..max_retries { - let output = self.process.execute(&full_args_refs, cwd)?; - if output.status == 0 { - return Ok(output); - } - // Check if it's an auth error (SVN exit code or stderr hint) - if !output.stderr.contains("authorization failed") - && !output.stderr.contains("Could not authenticate") - && !output.stderr.contains("Authentication failed") - { - // Not an auth error, return immediately - last_output = Some(output); - break; - } - last_output = Some(output); - } - - match last_output { - Some(output) if output.status != 0 => { - anyhow::bail!( - "SVN command `{}` failed with exit code {}\nstderr: {}", - full_args_refs.join(" "), - output.status, - output.stderr.trim(), - ); - } - Some(output) => Ok(output), - None => anyhow::bail!("SVN command failed with no output"), - } - } -} diff --git a/crates/mozart-vcs/src/version_guesser.rs b/crates/mozart-vcs/src/version_guesser.rs deleted file mode 100644 index 038e332..0000000 --- a/crates/mozart-vcs/src/version_guesser.rs +++ /dev/null @@ -1,605 +0,0 @@ -//! `VersionGuesser` — derive a package's current version from the working -//! copy, mirroring `Composer\Package\Version\VersionGuesser`. -//! -//! Differences from the PHP version: -//! - Fossil is not supported (Mozart has no Fossil driver). -//! - `Platform::isInputCompletionProcess()` short-circuit is omitted. -//! - `guess_feature_version` runs candidate comparisons sequentially. -//! Composer parallelises via `executeAsync`; ours is simpler at the -//! cost of speed when many candidate branches exist. - -use std::path::Path; -use std::sync::LazyLock; - -use regex::Regex; -use serde_json::Value; - -use mozart_semver::{Version, normalize_branch}; - -use crate::process::ProcessExecutor; - -const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev"; - -/// Mirrors `Composer\Package\Version\VersionParser` (itself a thin wrapper -/// around `Composer\Semver\VersionParser`). In Rust, semver parsing is -/// handled by `mozart_semver` directly, so this type carries no state; -/// it exists to keep `VersionGuesser::new` signature compatible with the -/// PHP constructor. -pub struct VersionParser; - -impl Default for VersionParser { - fn default() -> Self { - Self::new() - } -} - -impl VersionParser { - pub fn new() -> Self { - Self - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GuessedVersion { - pub version: String, - pub commit: Option<String>, - pub pretty_version: Option<String>, - pub feature_version: Option<String>, - pub feature_pretty_version: Option<String>, -} - -pub struct VersionGuesser { - process: ProcessExecutor, -} - -impl Default for VersionGuesser { - fn default() -> Self { - Self::new(VersionParser::new()) - } -} - -impl VersionGuesser { - /// Mirrors `Composer\Package\Version\VersionGuesser::__construct`. - /// `_version_parser` is accepted for API parity but unused — Rust relies - /// on `mozart_semver` directly. - pub fn new(_version_parser: VersionParser) -> Self { - Self { - process: ProcessExecutor::new(), - } - } - - /// `Composer\Package\Version\VersionGuesser::guessVersion`. - pub fn guess_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> { - if let Some(v) = self.guess_git_version(package_config, path) { - return Some(postprocess(v)); - } - if let Some(v) = self.guess_hg_version(package_config, path) { - return Some(postprocess(v)); - } - if let Some(v) = self.guess_svn_version(package_config, path) { - return Some(postprocess(v)); - } - None - } - - fn guess_git_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> { - let mut commit: Option<String> = None; - let mut version: Option<String> = None; - let mut pretty_version: Option<String> = None; - let mut feature_version: Option<String> = None; - let mut feature_pretty_version: Option<String> = None; - let mut is_detached = false; - - let branch_out = self - .process - .execute( - &["git", "branch", "-a", "--no-color", "--no-abbrev", "-v"], - Some(path), - ) - .ok()?; - if branch_out.status != 0 { - return None; - } - - let mut branches: Vec<String> = Vec::new(); - let mut is_feature_branch = false; - - for line in branch_out.stdout.lines() { - if line.is_empty() { - continue; - } - if let Some(caps) = CURRENT_BRANCH_RE.captures(line) { - let name = caps.get(1).map_or("", |m| m.as_str()); - let hash = caps.get(2).map_or("", |m| m.as_str()); - if name == "(no branch)" - || name.starts_with("(detached ") - || name.starts_with("(HEAD detached at") - { - let v = format!("dev-{hash}"); - version = Some(v.clone()); - pretty_version = Some(v); - is_feature_branch = true; - is_detached = true; - } else { - version = Some(normalize_branch(name)); - pretty_version = Some(format!("dev-{name}")); - is_feature_branch = is_feature_branch_name(package_config, name); - } - commit = Some(hash.to_string()); - } - - if !REMOTE_HEAD_RE.is_match(line) - && let Some(caps) = ANY_BRANCH_RE.captures(line) - && let Some(m) = caps.get(1) - { - branches.push(m.as_str().to_string()); - } - } - - if is_feature_branch { - feature_version = version.clone(); - feature_pretty_version = pretty_version.clone(); - let result = self.guess_feature_version( - package_config, - version.as_deref(), - &branches, - &["git", "rev-list", "%candidate%..%branch%"], - path, - ); - version = result.0; - pretty_version = result.1; - } - - if (version.is_none() || is_detached) - && let Some((tag_v, tag_pretty)) = self.version_from_git_tags(path) - { - version = Some(tag_v); - pretty_version = Some(tag_pretty); - feature_version = None; - feature_pretty_version = None; - } - - if commit.is_none() - && let Ok(out) = self - .process - .execute(&["git", "rev-parse", "HEAD"], Some(path)) - && out.status == 0 - { - let trimmed = out.stdout.trim(); - if !trimmed.is_empty() { - commit = Some(trimmed.to_string()); - } - } - - version.as_ref()?; - Some(GuessedVersion { - version: version.unwrap(), - commit, - pretty_version, - feature_version, - feature_pretty_version, - }) - } - - fn version_from_git_tags(&self, path: &Path) -> Option<(String, String)> { - let out = self - .process - .execute(&["git", "describe", "--exact-match", "--tags"], Some(path)) - .ok()?; - if out.status != 0 { - return None; - } - let pretty = out.stdout.trim().to_string(); - if pretty.is_empty() { - return None; - } - let normalized = Version::parse(&pretty).ok()?; - Some((normalized.to_string(), pretty)) - } - - fn guess_hg_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> { - let out = self.process.execute(&["hg", "branch"], Some(path)).ok()?; - if out.status != 0 { - return None; - } - let branch = out.stdout.trim().to_string(); - if branch.is_empty() { - return None; - } - let version = normalize_branch(&branch); - let is_feature = version.starts_with("dev-"); - - if version == DEFAULT_BRANCH_ALIAS { - return Some(GuessedVersion { - version, - commit: None, - pretty_version: Some(format!("dev-{branch}")), - feature_version: None, - feature_pretty_version: None, - }); - } - - if !is_feature { - return Some(GuessedVersion { - version: version.clone(), - commit: None, - pretty_version: Some(version), - feature_version: None, - feature_pretty_version: None, - }); - } - - // List branches via `hg branches` (first whitespace-separated token per line). - let branches_out = self.process.execute(&["hg", "branches"], Some(path)).ok()?; - let branches: Vec<String> = if branches_out.status == 0 { - branches_out - .stdout - .lines() - .filter_map(|l| l.split_whitespace().next().map(str::to_string)) - .collect() - } else { - Vec::new() - }; - - let (out_version, out_pretty) = self.guess_feature_version( - package_config, - Some(&version), - &branches, - &[ - "hg", - "log", - "-r", - "not ancestors('%candidate%') and ancestors('%branch%')", - "--template", - "\"{node}\\n\"", - ], - path, - ); - - Some(GuessedVersion { - version: out_version.unwrap_or(version.clone()), - commit: Some(String::new()), - pretty_version: out_pretty, - feature_version: Some(version.clone()), - feature_pretty_version: Some(version), - }) - } - - fn guess_svn_version(&self, package_config: &Value, path: &Path) -> Option<GuessedVersion> { - let out = self - .process - .execute(&["svn", "info", "--xml"], Some(path)) - .ok()?; - if out.status != 0 { - return None; - } - - let trunk = package_config - .get("trunk-path") - .and_then(Value::as_str) - .unwrap_or("trunk"); - let branches = package_config - .get("branches-path") - .and_then(Value::as_str) - .unwrap_or("branches"); - let tags = package_config - .get("tags-path") - .and_then(Value::as_str) - .unwrap_or("tags"); - - let pattern = format!( - r"<url>.*/({trunk}|({branches}|{tags})/(.*))</url>", - trunk = regex::escape(trunk), - branches = regex::escape(branches), - tags = regex::escape(tags), - ); - let re = Regex::new(&pattern).ok()?; - let caps = re.captures(&out.stdout)?; - - let kind = caps.get(2).map(|m| m.as_str().to_string()); - let inner = caps.get(3).map(|m| m.as_str().to_string()); - - if let (Some(kind), Some(inner)) = (kind, inner) - && (kind == branches || kind == tags) - { - let pretty = format!("dev-{inner}"); - return Some(GuessedVersion { - version: normalize_branch(&inner), - commit: Some(String::new()), - pretty_version: Some(pretty), - feature_version: None, - feature_pretty_version: None, - }); - } - - let trunk_match = caps.get(1)?; - let pretty = trunk_match.as_str().trim().to_string(); - let version = if pretty == "trunk" { - "dev-trunk".to_string() - } else { - Version::parse(&pretty).ok()?.to_string() - }; - Some(GuessedVersion { - version, - commit: Some(String::new()), - pretty_version: Some(pretty), - feature_version: None, - feature_pretty_version: None, - }) - } - - /// Find the nearest non-feature branch by diff size. Sequential port of - /// `guessFeatureVersion`; Composer runs candidates in parallel. - fn guess_feature_version( - &self, - package_config: &Value, - version: Option<&str>, - branches: &[String], - scm_cmdline: &[&str], - path: &Path, - ) -> (Option<String>, Option<String>) { - let version = version.map(str::to_string); - let pretty_version = version.clone(); - - let Some(v) = version.clone() else { - return (version, pretty_version); - }; - - // Skip if the branch has a non-self.version branch-alias OR self.version is referenced. - let has_branch_alias = package_config - .get("extra") - .and_then(|e| e.get("branch-alias")) - .and_then(|b| b.get(&v)) - .is_some(); - let uses_self_version = serde_json::to_string(package_config) - .map(|s| s.contains("\"self.version\"")) - .unwrap_or(false); - if has_branch_alias && !uses_self_version { - return (Some(v), pretty_version); - } - - // Composer also returns early if `self.version` is referenced — see L283. - // The PHP precedence is: skip iff (no branch-alias) OR (json contains self.version). - if uses_self_version { - return (Some(v), pretty_version); - } - - let branch = v.strip_prefix("dev-").unwrap_or(&v).to_string(); - - if !is_feature_branch_name(package_config, &branch) { - return (Some(v), pretty_version); - } - - let mut sorted: Vec<String> = branches.to_vec(); - sorted.sort_by(|a, b| { - let a_remote = a.starts_with("remotes/"); - let b_remote = b.starts_with("remotes/"); - if a_remote != b_remote { - return if a_remote { - std::cmp::Ordering::Greater - } else { - std::cmp::Ordering::Less - }; - } - // strnatcasecmp(b, a) — natural-sort, descending, case-insensitive. - natural_cmp(&b.to_ascii_lowercase(), &a.to_ascii_lowercase()) - }); - - let mut last_index: i64 = -1; - let mut length: usize = usize::MAX; - let mut version = Some(v); - let mut pretty = pretty_version; - - for (index, candidate) in sorted.iter().enumerate() { - let candidate_version = REMOTES_PREFIX_RE.replace(candidate, "").to_string(); - if candidate.as_str() == branch.as_str() - || is_feature_branch_name(package_config, &candidate_version) - { - continue; - } - let cmd: Vec<String> = scm_cmdline - .iter() - .map(|c| { - c.replace("%candidate%", candidate) - .replace("%branch%", &branch) - }) - .collect(); - let cmd_refs: Vec<&str> = cmd.iter().map(String::as_str).collect(); - let Ok(output) = self.process.execute(&cmd_refs, Some(path)) else { - continue; - }; - if output.status != 0 { - continue; - } - let len = output.stdout.len(); - if len < length || (len == length && last_index < index as i64) { - last_index = index as i64; - length = len; - version = Some(normalize_branch(&candidate_version)); - pretty = Some(format!("dev-{candidate_version}")); - if length == 0 { - break; - } - } - } - - (version, pretty) - } -} - -fn postprocess(mut v: GuessedVersion) -> GuessedVersion { - if v.feature_version.is_some() - && v.feature_version == Some(v.version.clone()) - && v.feature_pretty_version == v.pretty_version - { - v.feature_version = None; - v.feature_pretty_version = None; - } - - if v.version.ends_with("-dev") && contains_long_nines(&v.version) { - v.pretty_version = Some(replace_long_nines_with_x(&v.version)); - } - if let Some(ref fv) = v.feature_version - && fv.ends_with("-dev") - && contains_long_nines(fv) - { - v.feature_pretty_version = Some(replace_long_nines_with_x(fv)); - } - v -} - -fn contains_long_nines(s: &str) -> bool { - NINE_SEVEN_RE.is_match(s) -} - -fn replace_long_nines_with_x(s: &str) -> String { - NINE_SEVEN_GROUP_RE.replace_all(s, ".x").to_string() -} - -fn is_feature_branch_name(package_config: &Value, branch_name: &str) -> bool { - let mut non_feature = String::new(); - if let Some(arr) = package_config - .get("non-feature-branches") - .and_then(Value::as_array) - { - let parts: Vec<String> = arr - .iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect(); - if !parts.is_empty() { - non_feature = parts.join("|"); - } - } - let pattern = format!( - r"^({non_feature}|master|main|latest|next|current|support|tip|trunk|default|develop|\d+\..+)$" - ); - let Ok(re) = Regex::new(&pattern) else { - return true; - }; - !re.is_match(branch_name) -} - -/// Natural-order, case-insensitive string comparison (mirrors PHP `strnatcasecmp`). -fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { - let mut ai = a.chars().peekable(); - let mut bi = b.chars().peekable(); - loop { - match (ai.peek().copied(), bi.peek().copied()) { - (None, None) => return std::cmp::Ordering::Equal, - (None, _) => return std::cmp::Ordering::Less, - (_, None) => return std::cmp::Ordering::Greater, - (Some(ac), Some(bc)) => { - if ac.is_ascii_digit() && bc.is_ascii_digit() { - let mut na = String::new(); - let mut nb = String::new(); - while let Some(&c) = ai.peek() { - if !c.is_ascii_digit() { - break; - } - na.push(c); - ai.next(); - } - while let Some(&c) = bi.peek() { - if !c.is_ascii_digit() { - break; - } - nb.push(c); - bi.next(); - } - let na_v: u128 = na.parse().unwrap_or(0); - let nb_v: u128 = nb.parse().unwrap_or(0); - match na_v.cmp(&nb_v) { - std::cmp::Ordering::Equal => continue, - ord => return ord, - } - } else { - match ac.cmp(&bc) { - std::cmp::Ordering::Equal => { - ai.next(); - bi.next(); - } - ord => return ord, - } - } - } - } - } -} - -static CURRENT_BRANCH_RE: LazyLock<Regex> = LazyLock::new(|| { - Regex::new( - r"^(?:\* ) *(\(no branch\)|\(detached from \S+\)|\(HEAD detached at \S+\)|\S+) *([a-f0-9]+) .*$", - ) - .unwrap() -}); - -static REMOTE_HEAD_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^ *.+/HEAD ").unwrap()); - -static ANY_BRANCH_RE: LazyLock<Regex> = LazyLock::new(|| { - Regex::new(r"^(?:\* )? *((?:remotes/(?:origin|upstream)/)?[^\s/]+) *([a-f0-9]+) .*$").unwrap() -}); - -static REMOTES_PREFIX_RE: LazyLock<Regex> = - LazyLock::new(|| Regex::new(r"^remotes/[^/]+/").unwrap()); - -static NINE_SEVEN_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.9{7}").unwrap()); - -static NINE_SEVEN_GROUP_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(\.9{7})+").unwrap()); - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn test_postprocess_strips_duplicate_feature() { - let v = GuessedVersion { - version: "1.0.0.0".into(), - commit: None, - pretty_version: Some("1.0.0".into()), - feature_version: Some("1.0.0.0".into()), - feature_pretty_version: Some("1.0.0".into()), - }; - let p = postprocess(v); - assert_eq!(p.feature_version, None); - assert_eq!(p.feature_pretty_version, None); - } - - #[test] - fn test_postprocess_nine_seven_to_x() { - let v = GuessedVersion { - version: "1.9999999.9999999.9999999-dev".into(), - commit: None, - pretty_version: Some("dev-1.x".into()), - feature_version: None, - feature_pretty_version: None, - }; - let p = postprocess(v); - assert_eq!(p.pretty_version.as_deref(), Some("1.x-dev")); - } - - #[test] - fn test_is_feature_branch_known_mainlines() { - let cfg = json!({}); - assert!(!is_feature_branch_name(&cfg, "master")); - assert!(!is_feature_branch_name(&cfg, "main")); - assert!(!is_feature_branch_name(&cfg, "develop")); - assert!(!is_feature_branch_name(&cfg, "1.0")); - assert!(is_feature_branch_name(&cfg, "feature/x")); - } - - #[test] - fn test_is_feature_branch_with_non_feature_list() { - let cfg = json!({"non-feature-branches": ["staging", "release-.+"]}); - assert!(!is_feature_branch_name(&cfg, "staging")); - assert!(!is_feature_branch_name(&cfg, "release-2")); - assert!(is_feature_branch_name(&cfg, "wip-x")); - } - - #[test] - fn test_natural_cmp_orders_naturally() { - assert_eq!(natural_cmp("1.10", "1.9"), std::cmp::Ordering::Greater); - assert_eq!(natural_cmp("1.2", "1.10"), std::cmp::Ordering::Less); - assert_eq!(natural_cmp("abc", "abc"), std::cmp::Ordering::Equal); - } -} diff --git a/crates/mozart-vcs/tests/git_driver_test.rs b/crates/mozart-vcs/tests/git_driver_test.rs deleted file mode 100644 index dd72ad6..0000000 --- a/crates/mozart-vcs/tests/git_driver_test.rs +++ /dev/null @@ -1,340 +0,0 @@ -use std::path::Path; -use std::process::Command; - -use tempfile::TempDir; - -use mozart_vcs::downloader::VcsDownloader; -use mozart_vcs::downloader::git::GitDownloader; -use mozart_vcs::driver::{DriverConfig, DriverType, create_driver}; -use mozart_vcs::process::ProcessExecutor; -use mozart_vcs::util::git::GitUtil; - -fn has_git() -> bool { - Command::new("git").arg("--version").output().is_ok() -} - -fn create_test_repo(dir: &Path) { - let run = |args: &[&str]| { - let output = Command::new(args[0]) - .args(&args[1..]) - .current_dir(dir) - .env("GIT_AUTHOR_NAME", "Test") - .env("GIT_AUTHOR_EMAIL", "test@test.com") - .env("GIT_COMMITTER_NAME", "Test") - .env("GIT_COMMITTER_EMAIL", "test@test.com") - .output() - .unwrap(); - assert!( - output.status.success(), - "Command failed: {:?}\nstderr: {}", - args, - String::from_utf8_lossy(&output.stderr) - ); - }; - - run(&["git", "init", "-b", "main"]); - run(&["git", "config", "user.email", "test@test.com"]); - run(&["git", "config", "user.name", "Test"]); - - // Create composer.json - std::fs::write( - dir.join("composer.json"), - r#"{"name": "test/package", "description": "Test package"}"#, - ) - .unwrap(); - - run(&["git", "add", "."]); - run(&["git", "commit", "-m", "Initial commit"]); - - // Create a tag - run(&["git", "tag", "v1.0.0"]); - - // Create another commit on main - std::fs::write(dir.join("README.md"), "# Test").unwrap(); - run(&["git", "add", "."]); - run(&["git", "commit", "-m", "Add readme"]); - - // Create a second tag - run(&["git", "tag", "v1.1.0"]); - - // Create a feature branch - run(&["git", "checkout", "-b", "feature/test"]); - std::fs::write(dir.join("feature.txt"), "feature").unwrap(); - run(&["git", "add", "."]); - run(&["git", "commit", "-m", "Feature commit"]); - run(&["git", "checkout", "main"]); -} - -#[tokio::test] -async fn test_git_driver_local_repo() { - if !has_git() { - eprintln!("Skipping test: git not available"); - return; - } - - let repo_dir = TempDir::new().unwrap(); - let cache_dir = TempDir::new().unwrap(); - create_test_repo(repo_dir.path()); - - let config = DriverConfig { - cache_vcs_dir: cache_dir.path().to_path_buf(), - ..DriverConfig::default() - }; - - let mut driver = create_driver(repo_dir.path().to_str().unwrap(), DriverType::Git, config); - - driver.initialize().await.unwrap(); - assert_eq!(driver.root_identifier(), "main"); - - // Check tags - let tags = driver.tags().await.unwrap().clone(); - assert!( - tags.contains_key("v1.0.0"), - "Missing tag v1.0.0: {:?}", - tags - ); - assert!( - tags.contains_key("v1.1.0"), - "Missing tag v1.1.0: {:?}", - tags - ); - - // Check branches - let branches = driver.branches().await.unwrap().clone(); - assert!( - branches.contains_key("main"), - "Missing branch main: {:?}", - branches - ); - assert!( - branches.contains_key("feature/test"), - "Missing branch feature/test: {:?}", - branches, - ); - - // Read composer.json - let tag_hash = &tags["v1.0.0"]; - let info = driver.composer_information(tag_hash).await.unwrap(); - assert!(info.is_some()); - let info = info.unwrap(); - assert_eq!(info["name"].as_str(), Some("test/package")); - - // Read file content - let content = driver - .file_content("composer.json", tag_hash) - .await - .unwrap(); - assert!(content.is_some()); - assert!(content.unwrap().contains("test/package")); - - // Change date - let date = driver.change_date(tag_hash).await.unwrap(); - assert!(date.is_some()); - - // Source reference - let source = driver.source(tag_hash); - assert_eq!(source.source_type, "git"); - - driver.cleanup().await.unwrap(); -} - -#[test] -fn test_git_downloader() { - if !has_git() { - eprintln!("Skipping test: git not available"); - return; - } - - let repo_dir = TempDir::new().unwrap(); - let cache_dir = TempDir::new().unwrap(); - let install_dir = TempDir::new().unwrap(); - create_test_repo(repo_dir.path()); - - let process = ProcessExecutor::new(); - let git_util = GitUtil::new(process, cache_dir.path().join("git")); - let downloader = GitDownloader::new(git_util); - - let url = repo_dir.path().to_str().unwrap(); - let target = install_dir.path().join("test-package"); - - // Download (sync mirror) - downloader.download(url, "v1.0.0", &target).unwrap(); - - // Install - downloader.install(url, "v1.0.0", &target).unwrap(); - assert!(target.join("composer.json").exists()); - - // Check no local changes - let changes = downloader.get_local_changes(&target).unwrap(); - assert!(changes.is_none(), "Expected no changes, got: {:?}", changes); - - // Untracked files alone must NOT count as local changes (matches - // Composer's `git status --porcelain --untracked-files=no`). - std::fs::write(target.join("untracked.txt"), "untracked").unwrap(); - let changes = downloader.get_local_changes(&target).unwrap(); - assert!( - changes.is_none(), - "Untracked files should be ignored, got: {:?}", - changes - ); - - // Modifying a tracked file is a local change. - std::fs::write(target.join("composer.json"), "{\"name\":\"changed\"}\n").unwrap(); - let changes = downloader.get_local_changes(&target).unwrap(); - assert!(changes.is_some()); - assert!(changes.unwrap().contains("composer.json")); - - // Commit logs - let logs = downloader.commit_logs("v1.0.0", "v1.1.0", &target).unwrap(); - assert!(logs.contains("Add readme")); - - // Remove - downloader.remove(&target).unwrap(); - assert!(!target.exists()); -} - -#[test] -fn test_git_downloader_unpushed_changes() { - if !has_git() { - eprintln!("Skipping test: git not available"); - return; - } - - let repo_dir = TempDir::new().unwrap(); - let cache_dir = TempDir::new().unwrap(); - let install_dir = TempDir::new().unwrap(); - create_test_repo(repo_dir.path()); - - let process = ProcessExecutor::new(); - let git_util = GitUtil::new(process, cache_dir.path().join("git")); - let downloader = GitDownloader::new(git_util); - - let url = repo_dir.path().to_str().unwrap(); - let target = install_dir.path().join("test-package"); - - downloader.download(url, "main", &target).unwrap(); - downloader.install(url, "main", &target).unwrap(); - - // No commits added locally → no unpushed changes. - let unpushed = downloader.unpushed_changes(&target).unwrap(); - assert!( - unpushed.is_none(), - "Expected no unpushed changes, got: {:?}", - unpushed - ); - - // Commit a local change without pushing. - let run = |args: &[&str]| { - let output = Command::new(args[0]) - .args(&args[1..]) - .current_dir(&target) - .env("GIT_AUTHOR_NAME", "Test") - .env("GIT_AUTHOR_EMAIL", "test@test.com") - .env("GIT_COMMITTER_NAME", "Test") - .env("GIT_COMMITTER_EMAIL", "test@test.com") - .output() - .unwrap(); - assert!(output.status.success(), "Command failed: {:?}", args); - }; - std::fs::write(target.join("local-only.txt"), "local-only").unwrap(); - run(&["git", "add", "."]); - run(&["git", "commit", "-m", "Local-only commit"]); - - let unpushed = downloader.unpushed_changes(&target).unwrap(); - assert!(unpushed.is_some(), "Expected unpushed changes"); - let body = unpushed.unwrap(); - assert!( - body.contains("local-only.txt"), - "Expected diff body to mention local-only.txt, got: {body}" - ); -} - -#[test] -fn test_detect_driver() { - use mozart_vcs::driver::{DriverType, detect_driver}; - - let config = DriverConfig::default(); - - assert_eq!( - detect_driver("https://github.com/owner/repo", None, &config), - Some(DriverType::GitHub), - ); - assert_eq!( - detect_driver("git@github.com:owner/repo.git", None, &config), - Some(DriverType::GitHub), - ); - assert_eq!( - detect_driver("https://gitlab.com/owner/repo", None, &config), - Some(DriverType::GitLab), - ); - assert_eq!( - detect_driver("https://bitbucket.org/owner/repo", None, &config), - Some(DriverType::Bitbucket), - ); - assert_eq!( - detect_driver("https://codeberg.org/owner/repo", None, &config), - Some(DriverType::Forgejo), - ); - assert_eq!( - detect_driver("git://example.com/repo.git", None, &config), - Some(DriverType::Git), - ); - assert_eq!( - detect_driver("svn://example.com/repo", None, &config), - Some(DriverType::Svn), - ); - - // Forced type - assert_eq!( - detect_driver("https://example.com/repo", Some("git"), &config), - Some(DriverType::Git), - ); -} - -#[tokio::test] -async fn test_vcs_repository_scan() { - if !has_git() { - eprintln!("Skipping test: git not available"); - return; - } - - let repo_dir = TempDir::new().unwrap(); - let cache_dir = TempDir::new().unwrap(); - create_test_repo(repo_dir.path()); - - let config = DriverConfig { - cache_vcs_dir: cache_dir.path().to_path_buf(), - ..DriverConfig::default() - }; - - let repo = mozart_vcs::repository::VcsRepository::new( - repo_dir.path().to_str().unwrap().to_string(), - None, - config, - ); - - let versions = repo.scan().await.unwrap(); - assert!(!versions.is_empty(), "No versions found"); - - // Should find tag versions - let tag_versions: Vec<_> = versions - .iter() - .filter(|v| !v.version.starts_with("dev-")) - .collect(); - assert!(!tag_versions.is_empty(), "No tag versions found"); - - // Should find branch versions - let dev_versions: Vec<_> = versions - .iter() - .filter(|v| v.version.starts_with("dev-")) - .collect(); - assert!(!dev_versions.is_empty(), "No dev versions found"); - - // Check default branch flag - let default_versions: Vec<_> = versions.iter().filter(|v| v.is_default_branch).collect(); - assert_eq!( - default_versions.len(), - 1, - "Expected exactly one default branch version" - ); -} |
