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-core/src/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-core/src/vcs')
19 files changed, 4054 insertions, 0 deletions
diff --git a/crates/mozart-core/src/vcs/downloader/git.rs b/crates/mozart-core/src/vcs/downloader/git.rs new file mode 100644 index 0000000..eb7a649 --- /dev/null +++ b/crates/mozart-core/src/vcs/downloader/git.rs @@ -0,0 +1,271 @@ +use super::super::process::ProcessExecutor; +use super::super::util::git::GitUtil; +use super::VcsDownloader; +use anyhow::Result; +use regex::Regex; +use std::path::Path; +use std::sync::LazyLock; + +/// 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-core/src/vcs/downloader/hg.rs b/crates/mozart-core/src/vcs/downloader/hg.rs new file mode 100644 index 0000000..33650f8 --- /dev/null +++ b/crates/mozart-core/src/vcs/downloader/hg.rs @@ -0,0 +1,84 @@ +use super::super::util::hg::HgUtil; +use super::VcsDownloader; +use anyhow::Result; +use std::path::Path; + +/// 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-core/src/vcs/downloader/mod.rs b/crates/mozart-core/src/vcs/downloader/mod.rs new file mode 100644 index 0000000..352f330 --- /dev/null +++ b/crates/mozart-core/src/vcs/downloader/mod.rs @@ -0,0 +1,56 @@ +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-core/src/vcs/downloader/svn.rs b/crates/mozart-core/src/vcs/downloader/svn.rs new file mode 100644 index 0000000..ea885ed --- /dev/null +++ b/crates/mozart-core/src/vcs/downloader/svn.rs @@ -0,0 +1,84 @@ +use super::super::util::svn::SvnUtil; +use super::VcsDownloader; +use anyhow::Result; +use regex::Regex; +use std::path::Path; +use std::sync::LazyLock; + +/// 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-core/src/vcs/driver/bitbucket.rs b/crates/mozart-core/src/vcs/driver/bitbucket.rs new file mode 100644 index 0000000..2235e10 --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/bitbucket.rs @@ -0,0 +1,277 @@ +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: crate::http::default_client(), + config, + api_failed: false, + vcs_type: "git".to_string(), + } + } + + pub fn supports(url: &str) -> bool { + let url_lower = url.to_lowercase(); + url_lower.contains("bitbucket.org") + } + + fn parse_url(url: &str) -> Option<(String, String)> { + let re = + Regex::new(r"bitbucket\.org[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; + let caps = re.captures(url)?; + Some((caps[1].to_string(), caps[2].to_string())) + } + + fn api_url(&self, path: &str) -> String { + format!( + "https://api.bitbucket.org/2.0/repositories/{}/{}{}", + self.owner, self.repo, path, + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result<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-core/src/vcs/driver/forgejo.rs b/crates/mozart-core/src/vcs/driver/forgejo.rs new file mode 100644 index 0000000..8a290c0 --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/forgejo.rs @@ -0,0 +1,285 @@ +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: crate::http::default_client(), + config, + api_failed: false, + } + } + + pub fn supports(url: &str, forgejo_domains: &[String]) -> bool { + let url_lower = url.to_lowercase(); + for domain in forgejo_domains { + if url_lower.contains(domain) { + return true; + } + } + false + } + + fn parse_url(url: &str) -> Option<(String, String, String, String)> { + let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") + .ok()?; + let caps = re.captures(url)?; + Some(( + caps[2].to_string(), + caps[1].to_string(), + caps[3].to_string(), + caps[4].to_string(), + )) + } + + fn api_url(&self, path: &str) -> String { + format!( + "{}://{}/api/v1/repos/{}/{}{}", + self.scheme, self.host, self.owner, self.repo, path, + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result<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-core/src/vcs/driver/git.rs b/crates/mozart-core/src/vcs/driver/git.rs new file mode 100644 index 0000000..7d6643f --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/git.rs @@ -0,0 +1,275 @@ +use super::super::process::ProcessExecutor; +use super::super::util::git::GitUtil; +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; +use anyhow::Result; +use indexmap::IndexMap; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Git VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\GitDriver`. +pub struct GitDriver { + url: String, + repo_dir: Option<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-core/src/vcs/driver/github.rs b/crates/mozart-core/src/vcs/driver/github.rs new file mode 100644 index 0000000..7772bbb --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/github.rs @@ -0,0 +1,315 @@ +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: crate::http::default_client(), + config, + api_failed: false, + } + } + + /// Check if a URL points to GitHub. + pub fn supports(url: &str) -> bool { + let url_lower = url.to_lowercase(); + url_lower.contains("github.com") + && (url_lower.contains("github.com/") || url_lower.contains("github.com:")) + } + + fn parse_url(url: &str) -> Option<(String, String)> { + let re = Regex::new(r"github\.com[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?; + let caps = re.captures(url)?; + Some((caps[1].to_string(), caps[2].to_string())) + } + + fn api_url(&self, path: &str) -> String { + format!( + "https://api.github.com/repos/{}/{}{}", + self.owner, self.repo, path + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result<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-core/src/vcs/driver/gitlab.rs b/crates/mozart-core/src/vcs/driver/gitlab.rs new file mode 100644 index 0000000..f181e63 --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/gitlab.rs @@ -0,0 +1,301 @@ +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: crate::http::default_client(), + config, + api_failed: false, + } + } + + pub fn supports(url: &str, gitlab_domains: &[String]) -> bool { + let url_lower = url.to_lowercase(); + for domain in gitlab_domains { + if url_lower.contains(domain) { + return true; + } + } + false + } + + fn parse_url(url: &str) -> Option<(String, String, String, String)> { + let re = Regex::new(r"(?i)(https?)://([^/]+)/([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$") + .ok()?; + let caps = re.captures(url)?; + Some(( + caps[2].to_string(), + caps[1].to_string(), + caps[3].to_string(), + caps[4].to_string(), + )) + } + + fn api_url(&self, path: &str) -> String { + let project_path = format!("{}%2F{}", self.owner, self.repo); + let id = self.project_id.as_deref().unwrap_or(&project_path); + format!( + "{}://{}/api/v4/projects/{}{}", + self.scheme, self.host, id, path + ) + } + + #[tracing::instrument(skip(self))] + async fn api_get(&self, path: &str) -> Result<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-core/src/vcs/driver/hg.rs b/crates/mozart-core/src/vcs/driver/hg.rs new file mode 100644 index 0000000..e2c3fcd --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/hg.rs @@ -0,0 +1,202 @@ +use super::super::process::ProcessExecutor; +use super::super::util::hg::HgUtil; +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; +use anyhow::Result; +use indexmap::IndexMap; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// Mercurial VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\HgDriver`. +pub struct HgDriver { + url: String, + repo_dir: Option<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(super::super::util::git::GitUtil::sanitize_url(&self.url)); + + if repo_dir.join(".hg").is_dir() { + // Update existing clone + self.hg_util.execute(&["pull"], Some(&repo_dir))?; + } else { + // Clone without checkout + let dir_str = repo_dir.to_string_lossy().to_string(); + self.hg_util + .execute(&["clone", "--noupdate", &self.url, &dir_str], None)?; + } + + self.repo_dir = Some(repo_dir.clone()); + + // Get default branch + let output = self.hg_util.execute( + &["log", "-r", "default", "--template", "{node|short}"], + Some(&repo_dir), + ); + self.root_identifier = match output { + Ok(o) if !o.stdout.trim().is_empty() => Some("default".to_string()), + _ => Some("tip".to_string()), + }; + + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("default") + } + + async fn branches(&mut self) -> Result<&BTreeMap<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-core/src/vcs/driver/mod.rs b/crates/mozart-core/src/vcs/driver/mod.rs new file mode 100644 index 0000000..cfaf11e --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/mod.rs @@ -0,0 +1,309 @@ +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-core/src/vcs/driver/svn.rs b/crates/mozart-core/src/vcs/driver/svn.rs new file mode 100644 index 0000000..7ba9e86 --- /dev/null +++ b/crates/mozart-core/src/vcs/driver/svn.rs @@ -0,0 +1,214 @@ +use super::super::process::ProcessExecutor; +use super::super::util::svn::SvnUtil; +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; +use anyhow::Result; +use indexmap::IndexMap; +use regex::Regex; +use std::collections::BTreeMap; + +/// SVN VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\SvnDriver`. +pub struct SvnDriver { + url: String, + base_url: String, + trunk_path: String, + branches_path: String, + tags_path: String, + root_identifier: Option<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-core/src/vcs/process.rs b/crates/mozart-core/src/vcs/process.rs new file mode 100644 index 0000000..8ccc11d --- /dev/null +++ b/crates/mozart-core/src/vcs/process.rs @@ -0,0 +1,142 @@ +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-core/src/vcs/repository.rs b/crates/mozart-core/src/vcs/repository.rs new file mode 100644 index 0000000..55f98f9 --- /dev/null +++ b/crates/mozart-core/src/vcs/repository.rs @@ -0,0 +1,205 @@ +use super::driver::{ + DistReference, DriverConfig, DriverType, SourceReference, create_driver, detect_driver, +}; +use anyhow::{Result, bail}; + +/// 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-core/src/vcs/util/git.rs b/crates/mozart-core/src/vcs/util/git.rs new file mode 100644 index 0000000..15bfa09 --- /dev/null +++ b/crates/mozart-core/src/vcs/util/git.rs @@ -0,0 +1,312 @@ +use super::super::process::{ProcessExecutor, ProcessOutput}; +use anyhow::{Result, bail}; +use regex::Regex; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +/// 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-core/src/vcs/util/hg.rs b/crates/mozart-core/src/vcs/util/hg.rs new file mode 100644 index 0000000..73051b7 --- /dev/null +++ b/crates/mozart-core/src/vcs/util/hg.rs @@ -0,0 +1,28 @@ +use super::super::process::{ProcessExecutor, ProcessOutput}; +use anyhow::Result; +use std::path::Path; + +/// 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-core/src/vcs/util/mod.rs b/crates/mozart-core/src/vcs/util/mod.rs new file mode 100644 index 0000000..b2c35fc --- /dev/null +++ b/crates/mozart-core/src/vcs/util/mod.rs @@ -0,0 +1,3 @@ +pub mod git; +pub mod hg; +pub mod svn; diff --git a/crates/mozart-core/src/vcs/util/svn.rs b/crates/mozart-core/src/vcs/util/svn.rs new file mode 100644 index 0000000..d989fc8 --- /dev/null +++ b/crates/mozart-core/src/vcs/util/svn.rs @@ -0,0 +1,89 @@ +use super::super::process::{ProcessExecutor, ProcessOutput}; +use anyhow::Result; +use std::path::Path; + +/// 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-core/src/vcs/version_guesser.rs b/crates/mozart-core/src/vcs/version_guesser.rs new file mode 100644 index 0000000..58b758e --- /dev/null +++ b/crates/mozart-core/src/vcs/version_guesser.rs @@ -0,0 +1,602 @@ +//! `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 super::process::ProcessExecutor; +use mozart_semver::{Version, normalize_branch}; +use regex::Regex; +use serde_json::Value; +use std::path::Path; +use std::sync::LazyLock; + +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); + } +} |
