diff options
Diffstat (limited to 'crates')
32 files changed, 3623 insertions, 33 deletions
diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml index 9d089e5..87d3c69 100644 --- a/crates/mozart-registry/Cargo.toml +++ b/crates/mozart-registry/Cargo.toml @@ -8,6 +8,7 @@ mozart-core.workspace = true mozart-metadata-minifier.workspace = true mozart-sat-resolver.workspace = true mozart-semver.workspace = true +mozart-vcs.workspace = true anyhow.workspace = true filetime.workspace = true flate2.workspace = true diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs index 9fd9aff..4c26c1e 100644 --- a/crates/mozart-registry/src/lib.rs +++ b/crates/mozart-registry/src/lib.rs @@ -4,4 +4,5 @@ pub mod installed; pub mod lockfile; pub mod packagist; pub mod resolver; +pub mod vcs_bridge; pub mod version; diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs index bfae4ee..8f27fbf 100644 --- a/crates/mozart-registry/src/lockfile.rs +++ b/crates/mozart-registry/src/lockfile.rs @@ -1032,6 +1032,7 @@ mod tests { ignore_platform_req_list: vec![], repo_cache: None, temporary_constraints: HashMap::new(), + repositories: vec![], }; let resolved = resolve(&resolve_request) diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs index 898a91c..4930b3a 100644 --- a/crates/mozart-registry/src/resolver.rs +++ b/crates/mozart-registry/src/resolver.rs @@ -9,7 +9,8 @@ use std::fmt; use crate::cache::Cache; use crate::packagist; -use mozart_core::package::Stability; +use crate::vcs_bridge; +use mozart_core::package::{RawRepository, Stability}; use mozart_sat_resolver::{ DefaultPolicy, PoolBuilder, PoolPackageInput, RuleSetGenerator, Solver, make_pool_links, }; @@ -20,7 +21,7 @@ use mozart_semver::Version; // ───────────────────────────────────────────────────────────────────────────── /// Determine the `Stability` of a `Version` from its pre_release string. -fn version_stability(v: &Version) -> Stability { +pub(crate) fn version_stability(v: &Version) -> Stability { match &v.pre_release { None => Stability::Stable, Some(pre) => { @@ -43,7 +44,7 @@ fn version_stability(v: &Version) -> Stability { /// Parse a Packagist normalized version string like "1.2.3.0", "1.0.0.0-beta1". /// Returns `None` for dev branches (dev-master, dev-*, *.x-dev). -fn parse_normalized(normalized: &str) -> Option<Version> { +pub(crate) fn parse_normalized(normalized: &str) -> Option<Version> { let s = normalized.trim(); // Reject dev branches @@ -348,6 +349,9 @@ pub struct ResolveRequest { /// Temporary version constraint overrides (from --with flag). /// Maps package name (lowercase) to constraint string. pub temporary_constraints: HashMap<String, String>, + /// VCS repositories from composer.json "repositories" section. + /// Used to fetch packages from VCS before falling back to Packagist. + pub repositories: Vec<RawRepository>, } /// A single package in the resolution output. @@ -413,6 +417,7 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R let prefer_lowest = request.prefer_lowest; let ignore_platform_reqs = request.ignore_platform_reqs; let ignore_platform_req_list = request.ignore_platform_req_list.clone(); + let vcs_repositories = request.repositories.clone(); // 2. Build pool, generate rules, and solve on a blocking thread tokio::task::spawn_blocking(move || -> Result<Vec<ResolvedPackage>, ResolveError> { @@ -447,12 +452,33 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R builder.add_package(input); } + // Scan VCS repositories and collect packages from them + let vcs_repos = &vcs_repositories; + let vcs_packages = vcs_bridge::scan_vcs_repositories(vcs_repos); + let mut vcs_package_names: HashSet<String> = HashSet::new(); + for vpkg in &vcs_packages { + vcs_package_names.insert(vpkg.name.clone()); + } + + // Add VCS packages to the pool + for vpkg in &vcs_packages { + let inputs = vcs_bridge::vcs_to_pool_inputs(vpkg, minimum_stability, &stability_flags); + for input in inputs { + builder.add_package(input); + } + } + // Seed the builder with packages for root requirements for name in root_requires.keys() { if PackageName(name.clone()).is_platform() { continue; // platform packages already added } + // Skip packages already provided by VCS repositories + if vcs_package_names.contains(name) { + continue; + } + // Fetch available versions from Packagist let versions = handle .block_on(packagist::fetch_package_versions(name, repo_cache.as_ref())) @@ -476,6 +502,11 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R continue; } + // Skip packages already provided by VCS repositories + if vcs_package_names.contains(&name) { + continue; + } + let versions = match handle.block_on(packagist::fetch_package_versions( &name, repo_cache.as_ref(), @@ -938,6 +969,7 @@ mod tests { ignore_platform_req_list: vec![], repo_cache: None, temporary_constraints: HashMap::new(), + repositories: vec![], }; let result = resolve(&request).await; diff --git a/crates/mozart-registry/src/vcs_bridge.rs b/crates/mozart-registry/src/vcs_bridge.rs new file mode 100644 index 0000000..d81cae8 --- /dev/null +++ b/crates/mozart-registry/src/vcs_bridge.rs @@ -0,0 +1,204 @@ +//! Bridge between `mozart-vcs` and `mozart-registry`. +//! +//! Scans VCS repositories defined in composer.json and converts +//! discovered package versions into pool inputs for the SAT resolver. + +use std::collections::{BTreeMap, HashMap}; + +use mozart_core::package::{RawRepository, Stability}; +use mozart_sat_resolver::{PoolPackageInput, make_pool_links}; +use mozart_vcs::driver::DriverConfig; +use mozart_vcs::repository::{VcsPackageVersion, VcsRepository}; + +use crate::packagist::PackagistVersion; +use crate::resolver::{parse_normalized, version_stability}; + +/// Scan all VCS-type repositories and collect package versions. +/// +/// Non-VCS repos (e.g. "composer", "package") are silently skipped. +pub fn scan_vcs_repositories(repositories: &[RawRepository]) -> Vec<VcsPackageVersion> { + let config = DriverConfig::default(); + let mut all_versions = Vec::new(); + + for repo in repositories { + let repo_type = repo.repo_type.as_str(); + match repo_type { + "vcs" | "git" | "svn" | "hg" | "github" | "gitlab" | "bitbucket" | "forgejo" => {} + _ => continue, + } + + let forced_type = match repo_type { + "vcs" => None, + other => Some(other), + }; + + let vcs_repo = VcsRepository::new(repo.url.clone(), forced_type, config.clone()); + + match vcs_repo.scan() { + Ok(versions) => { + all_versions.extend(versions); + } + Err(e) => { + eprintln!("Warning: Failed to scan VCS repository {}: {}", repo.url, e,); + } + } + } + + all_versions +} + +/// Convert a VCS package version to SAT pool inputs. +pub fn vcs_to_pool_inputs( + vpkg: &VcsPackageVersion, + minimum_stability: Stability, + stability_flags: &HashMap<String, Stability>, +) -> Vec<PoolPackageInput> { + let mut results = Vec::new(); + + // Extract dependency links from composer.json + let require = extract_dep_map(&vpkg.composer_json, "require"); + let replace = extract_dep_map(&vpkg.composer_json, "replace"); + let provide = extract_dep_map(&vpkg.composer_json, "provide"); + let conflict = extract_dep_map(&vpkg.composer_json, "conflict"); + + let input = PoolPackageInput { + name: vpkg.name.clone(), + version: vpkg.version_normalized.clone(), + pretty_version: vpkg.version.clone(), + requires: make_pool_links( + &vpkg.name, + &require + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::<Vec<_>>(), + ), + replaces: make_pool_links( + &vpkg.name, + &replace + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::<Vec<_>>(), + ), + provides: make_pool_links( + &vpkg.name, + &provide + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::<Vec<_>>(), + ), + conflicts: make_pool_links( + &vpkg.name, + &conflict + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect::<Vec<_>>(), + ), + is_fixed: false, + }; + + // Apply stability filtering + if let Some(v) = parse_normalized(&vpkg.version_normalized) { + if passes_vcs_stability_filter(&vpkg.name, &v, minimum_stability, stability_flags) { + results.push(input); + } + } else { + // Dev version: always include (dev stability) + let pkg_flag = stability_flags.get(&vpkg.name.to_lowercase()); + let allowed = pkg_flag.copied().unwrap_or(minimum_stability); + if allowed >= Stability::Dev { + results.push(input); + } + } + + results +} + +/// Convert a `VcsPackageVersion` into a `PackagistVersion` for lockfile generation. +pub fn vcs_to_packagist_version(vpkg: &VcsPackageVersion) -> PackagistVersion { + PackagistVersion { + version: vpkg.version.clone(), + version_normalized: vpkg.version_normalized.clone(), + require: extract_dep_map(&vpkg.composer_json, "require"), + replace: extract_dep_map(&vpkg.composer_json, "replace"), + provide: extract_dep_map(&vpkg.composer_json, "provide"), + conflict: extract_dep_map(&vpkg.composer_json, "conflict"), + dist: vpkg.dist.as_ref().map(|d| crate::packagist::PackagistDist { + dist_type: d.dist_type.clone(), + url: d.url.clone(), + reference: Some(d.reference.clone()), + shasum: d.shasum.clone(), + }), + source: Some(crate::packagist::PackagistSource { + source_type: vpkg.source.source_type.clone(), + url: vpkg.source.url.clone(), + reference: Some(vpkg.source.reference.clone()), + }), + require_dev: extract_dep_map(&vpkg.composer_json, "require-dev"), + suggest: vpkg + .composer_json + .get("suggest") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + package_type: vpkg + .composer_json + .get("type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + autoload: vpkg.composer_json.get("autoload").cloned(), + autoload_dev: vpkg.composer_json.get("autoload-dev").cloned(), + license: vpkg + .composer_json + .get("license") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + description: vpkg + .composer_json + .get("description") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + homepage: vpkg + .composer_json + .get("homepage") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + keywords: vpkg + .composer_json + .get("keywords") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + authors: vpkg + .composer_json + .get("authors") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + support: vpkg.composer_json.get("support").cloned(), + funding: vpkg + .composer_json + .get("funding") + .and_then(|v| serde_json::from_value(v.clone()).ok()), + time: vpkg.time.clone(), + extra: vpkg.composer_json.get("extra").cloned(), + notification_url: None, + } +} + +/// Extract a dependency map from composer.json JSON. +fn extract_dep_map(json: &serde_json::Value, key: &str) -> BTreeMap<String, String> { + json.get(key) + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() +} + +/// Stability filter for VCS packages (mirrors resolver logic). +fn passes_vcs_stability_filter( + package_name: &str, + version: &mozart_semver::Version, + minimum_stability: Stability, + stability_flags: &HashMap<String, Stability>, +) -> bool { + let stability = version_stability(version); + let pkg_flag = stability_flags.get(&package_name.to_lowercase()); + let allowed = pkg_flag.copied().unwrap_or(minimum_stability); + stability <= allowed +} diff --git a/crates/mozart-vcs/Cargo.toml b/crates/mozart-vcs/Cargo.toml new file mode 100644 index 0000000..61fd57e --- /dev/null +++ b/crates/mozart-vcs/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mozart-vcs" +version.workspace = true +edition.workspace = true + +[dependencies] +mozart-core.workspace = true +mozart-semver.workspace = true +anyhow.workspace = true +base64.workspace = true +regex.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sha1.workspace = true +tokio.workspace = true +url.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/mozart-vcs/src/downloader/git.rs b/crates/mozart-vcs/src/downloader/git.rs new file mode 100644 index 0000000..3bdb9ca --- /dev/null +++ b/crates/mozart-vcs/src/downloader/git.rs @@ -0,0 +1,112 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::process::ProcessExecutor; +use crate::util::git::GitUtil; + +use super::VcsDownloader; + +/// 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 local_changes(&self, target: &Path) -> Result<Option<String>> { + let process = ProcessExecutor::new(); + let output = process.execute(&["git", "status", "--porcelain"], Some(target))?; + if output.stdout.trim().is_empty() { + Ok(None) + } else { + Ok(Some(output.stdout)) + } + } + + 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) + } +} diff --git a/crates/mozart-vcs/src/downloader/hg.rs b/crates/mozart-vcs/src/downloader/hg.rs new file mode 100644 index 0000000..bfffa07 --- /dev/null +++ b/crates/mozart-vcs/src/downloader/hg.rs @@ -0,0 +1,71 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::util::hg::HgUtil; + +use super::VcsDownloader; + +/// Mercurial downloader using clone/pull/update. +pub struct HgDownloader { + hg_util: HgUtil, +} + +impl HgDownloader { + pub fn new(hg_util: HgUtil) -> Self { + Self { hg_util } + } +} + +impl VcsDownloader for HgDownloader { + fn download(&self, _url: &str, _reference: &str, _target: &Path) -> Result<()> { + Ok(()) + } + + fn install(&self, url: &str, reference: &str, target: &Path) -> Result<()> { + let target_str = target.to_string_lossy().to_string(); + self.hg_util + .execute(&["clone", "--", url, &target_str], None)?; + self.hg_util + .execute(&["update", "-r", reference], Some(target))?; + Ok(()) + } + + fn update(&self, url: &str, _old_ref: &str, new_ref: &str, target: &Path) -> Result<()> { + self.hg_util.execute(&["pull", url], Some(target))?; + self.hg_util + .execute(&["update", "-r", new_ref], Some(target))?; + Ok(()) + } + + fn remove(&self, target: &Path) -> Result<()> { + if target.exists() { + std::fs::remove_dir_all(target)?; + } + Ok(()) + } + + fn local_changes(&self, target: &Path) -> Result<Option<String>> { + let output = self.hg_util.execute(&["st"], Some(target))?; + if output.stdout.trim().is_empty() { + Ok(None) + } else { + Ok(Some(output.stdout)) + } + } + + 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) + } +} diff --git a/crates/mozart-vcs/src/downloader/mod.rs b/crates/mozart-vcs/src/downloader/mod.rs new file mode 100644 index 0000000..7186348 --- /dev/null +++ b/crates/mozart-vcs/src/downloader/mod.rs @@ -0,0 +1,31 @@ +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. + fn local_changes(&self, target: &Path) -> Result<Option<String>>; + + /// Get commit log between two references. + fn commit_logs(&self, from: &str, to: &str, target: &Path) -> Result<String>; +} diff --git a/crates/mozart-vcs/src/downloader/svn.rs b/crates/mozart-vcs/src/downloader/svn.rs new file mode 100644 index 0000000..5222b06 --- /dev/null +++ b/crates/mozart-vcs/src/downloader/svn.rs @@ -0,0 +1,64 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::util::svn::SvnUtil; + +use super::VcsDownloader; + +/// 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 local_changes(&self, target: &Path) -> Result<Option<String>> { + let output = self.svn_util.execute(&["status"], Some(target))?; + if output.stdout.trim().is_empty() { + Ok(None) + } else { + Ok(Some(output.stdout)) + } + } + + 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) + } +} diff --git a/crates/mozart-vcs/src/driver/bitbucket.rs b/crates/mozart-vcs/src/driver/bitbucket.rs new file mode 100644 index 0000000..9a0fc15 --- /dev/null +++ b/crates/mozart-vcs/src/driver/bitbucket.rs @@ -0,0 +1,272 @@ +use std::collections::{BTreeMap, HashMap}; + +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: HashMap<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: HashMap::new(), + git_driver: None, + http_client: Client::new(), + 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, + ) + } + + fn api_get(&self, path: &str) -> Result<serde_json::Value> { + let handle = tokio::runtime::Handle::current(); + 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 = handle.block_on(req.send())?; + if !response.status().is_success() { + bail!( + "Bitbucket API request to {} failed: {}", + url, + response.status() + ); + } + Ok(handle.block_on(response.json())?) + } + + fn api_get_paginated(&self, path: &str) -> Result<Vec<serde_json::Value>> { + let handle = tokio::runtime::Handle::current(); + 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 = handle.block_on(req.send())?; + if !response.status().is_success() { + break; + } + let data: serde_json::Value = handle.block_on(response.json())?; + 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) + } + + 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()?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriver for BitbucketDriver { + fn initialize(&mut self) -> Result<()> { + match self.api_get("") { + 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()?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + fn branches(&mut self) -> Result<&BTreeMap<String, String>> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback()?; + let branches = driver.branches()?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/refs/branches?pagelen=100")?; + 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()) + } + + fn tags(&mut self) -> Result<&BTreeMap<String, String>> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback()?; + let tags = driver.tags()?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/refs/tags?pagelen=100")?; + 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()) + } + + 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)?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> { + if self.api_failed { + return Ok(None); + } + let handle = tokio::runtime::Handle::current(); + 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 = handle.block_on(req.send())?; + if response.status().is_success() { + Ok(Some(handle.block_on(response.text())?)) + } else { + Ok(None) + } + } + + fn change_date(&self, identifier: &str) -> Result<Option<String>> { + if self.api_failed { + return Ok(None); + } + match self.api_get(&format!("/commit/{identifier}")) { + Ok(data) => Ok(data["date"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + 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 + } + + fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup()?; + } + Ok(()) + } +} diff --git a/crates/mozart-vcs/src/driver/forgejo.rs b/crates/mozart-vcs/src/driver/forgejo.rs new file mode 100644 index 0000000..f35f9df --- /dev/null +++ b/crates/mozart-vcs/src/driver/forgejo.rs @@ -0,0 +1,279 @@ +use std::collections::{BTreeMap, HashMap}; + +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: HashMap<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: HashMap::new(), + git_driver: None, + http_client: Client::new(), + 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, + ) + } + + fn api_get(&self, path: &str) -> Result<serde_json::Value> { + let handle = tokio::runtime::Handle::current(); + 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 = handle.block_on(req.send())?; + if !response.status().is_success() { + bail!( + "Forgejo API request to {} failed: {}", + url, + response.status() + ); + } + Ok(handle.block_on(response.json())?) + } + + 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)?; + 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) + } + + 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()?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriver for ForgejoDriver { + fn initialize(&mut self) -> Result<()> { + match self.api_get("") { + 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()?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + fn branches(&mut self) -> Result<&BTreeMap<String, String>> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback()?; + let branches = driver.branches()?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/branches")?; + 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()) + } + + fn tags(&mut self) -> Result<&BTreeMap<String, String>> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback()?; + let tags = driver.tags()?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/tags")?; + 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()) + } + + 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)?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + 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) { + 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), + } + } + + fn change_date(&self, identifier: &str) -> Result<Option<String>> { + if self.api_failed { + return Ok(None); + } + match self.api_get(&format!("/git/commits/{identifier}")) { + Ok(data) => Ok(data["created"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + 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 + } + + fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup()?; + } + Ok(()) + } +} diff --git a/crates/mozart-vcs/src/driver/git.rs b/crates/mozart-vcs/src/driver/git.rs new file mode 100644 index 0000000..13fc243 --- /dev/null +++ b/crates/mozart-vcs/src/driver/git.rs @@ -0,0 +1,274 @@ +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::process::ProcessExecutor; +use crate::util::git::GitUtil; + +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; + +/// Git VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\GitDriver`. +pub struct GitDriver { + url: String, + repo_dir: Option<PathBuf>, + root_identifier: Option<String>, + tags: Option<BTreeMap<String, String>>, + branches: Option<BTreeMap<String, String>>, + info_cache: HashMap<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_dir.join("git")); + 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: HashMap::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 = HashMap::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 { + 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") + } + + 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()) + } + + 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()) + } + + 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)?; + 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) + } + + 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) + } + } + + 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) + } + } + + 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 + } + + fn cleanup(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/crates/mozart-vcs/src/driver/github.rs b/crates/mozart-vcs/src/driver/github.rs new file mode 100644 index 0000000..23eaa8a --- /dev/null +++ b/crates/mozart-vcs/src/driver/github.rs @@ -0,0 +1,309 @@ +use std::collections::{BTreeMap, HashMap}; + +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: HashMap<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: HashMap::new(), + git_driver: None, + http_client: Client::new(), + 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 + ) + } + + fn api_get(&self, path: &str) -> Result<serde_json::Value> { + let handle = tokio::runtime::Handle::current(); + 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 = handle.block_on(req.send())?; + if !response.status().is_success() { + bail!( + "GitHub API request to {} failed with status {}", + url, + response.status() + ); + } + Ok(handle.block_on(response.json())?) + } + + fn api_get_paginated(&self, path: &str) -> Result<Vec<serde_json::Value>> { + let handle = tokio::runtime::Handle::current(); + 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 = handle.block_on(req.send())?; + if !response.status().is_success() { + bail!("GitHub API paginated request failed: {}", response.status()); + } + + let batch: Vec<serde_json::Value> = handle.block_on(response.json())?; + if batch.is_empty() { + break; + } + items.extend(batch); + page += 1; + // Safety: limit to 10 pages (1000 items) + if page > 10 { + break; + } + } + Ok(items) + } + + 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()?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriver for GitHubDriver { + fn initialize(&mut self) -> Result<()> { + // Try to fetch repo data from API + match self.api_get("") { + 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()?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + fn branches(&mut self) -> Result<&BTreeMap<String, String>> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback()?; + let branches = driver.branches()?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/branches")?; + 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()) + } + + fn tags(&mut self) -> Result<&BTreeMap<String, String>> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback()?; + let tags = driver.tags()?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/tags")?; + 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()) + } + + 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)?; + 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) + } + + 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) { + 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), + } + } + + 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) { + Ok(data) => { + let date = data["commit"]["committer"]["date"] + .as_str() + .map(|s| s.to_string()); + Ok(date) + } + Err(_) => Ok(None), + } + } + + 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 + } + + fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup()?; + } + Ok(()) + } +} + +/// Decode base64-encoded content from API responses. +/// Also used by Forgejo driver as `base64_decode_content`. +pub fn base64_decode_content(input: &str) -> Result<String> { + use base64::Engine; + let cleaned: Vec<u8> = input + .bytes() + .filter(|&b| b != b'\n' && b != b'\r') + .collect(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(&cleaned) + .map_err(|e| anyhow::anyhow!("Base64 decode error: {e}"))?; + String::from_utf8(decoded).map_err(|e| anyhow::anyhow!("Invalid UTF-8 in base64 content: {e}")) +} diff --git a/crates/mozart-vcs/src/driver/gitlab.rs b/crates/mozart-vcs/src/driver/gitlab.rs new file mode 100644 index 0000000..ed88f27 --- /dev/null +++ b/crates/mozart-vcs/src/driver/gitlab.rs @@ -0,0 +1,293 @@ +use std::collections::{BTreeMap, HashMap}; + +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: HashMap<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: HashMap::new(), + git_driver: None, + http_client: Client::new(), + 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 + ) + } + + fn api_get(&self, path: &str) -> Result<serde_json::Value> { + let handle = tokio::runtime::Handle::current(); + 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 = handle.block_on(req.send())?; + if !response.status().is_success() { + bail!( + "GitLab API request to {} failed with status {}", + url, + response.status() + ); + } + Ok(handle.block_on(response.json())?) + } + + 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)?; + 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) + } + + 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()?; + self.git_driver = Some(Box::new(driver)); + } + Ok(self.git_driver.as_mut().unwrap()) + } +} + +impl VcsDriver for GitLabDriver { + fn initialize(&mut self) -> Result<()> { + match self.api_get("") { + 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()?; + self.root_identifier = Some(driver.root_identifier().to_string()); + } + } + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("main") + } + + fn branches(&mut self) -> Result<&BTreeMap<String, String>> { + if self.branches.is_none() { + if self.api_failed { + let driver = self.use_git_fallback()?; + let branches = driver.branches()?.clone(); + self.branches = Some(branches); + } else { + let items = self.api_get_paginated("/repository/branches")?; + 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()) + } + + fn tags(&mut self) -> Result<&BTreeMap<String, String>> { + if self.tags.is_none() { + if self.api_failed { + let driver = self.use_git_fallback()?; + let tags = driver.tags()?.clone(); + self.tags = Some(tags); + } else { + let items = self.api_get_paginated("/repository/tags")?; + 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()) + } + + 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)?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>> { + if self.api_failed { + return Ok(None); + } + let handle = tokio::runtime::Handle::current(); + 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 = handle.block_on(req.send())?; + if response.status().is_success() { + Ok(Some(handle.block_on(response.text())?)) + } else { + Ok(None) + } + } + + fn change_date(&self, identifier: &str) -> Result<Option<String>> { + if self.api_failed { + return Ok(None); + } + match self.api_get(&format!("/repository/commits/{identifier}")) { + Ok(data) => Ok(data["committed_date"].as_str().map(|s| s.to_string())), + Err(_) => Ok(None), + } + } + + 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 + } + + fn cleanup(&mut self) -> Result<()> { + if let Some(driver) = &mut self.git_driver { + driver.cleanup()?; + } + Ok(()) + } +} diff --git a/crates/mozart-vcs/src/driver/hg.rs b/crates/mozart-vcs/src/driver/hg.rs new file mode 100644 index 0000000..7bfb07e --- /dev/null +++ b/crates/mozart-vcs/src/driver/hg.rs @@ -0,0 +1,201 @@ +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; + +use anyhow::Result; + +use crate::process::ProcessExecutor; +use crate::util::hg::HgUtil; + +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; + +/// Mercurial VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\HgDriver`. +pub struct HgDriver { + url: String, + repo_dir: Option<PathBuf>, + root_identifier: Option<String>, + tags: Option<BTreeMap<String, String>>, + branches: Option<BTreeMap<String, String>>, + info_cache: HashMap<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: HashMap::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 { + fn initialize(&mut self) -> Result<()> { + let cache_dir = self.config.cache_dir.join("hg"); + std::fs::create_dir_all(&cache_dir)?; + let repo_dir = cache_dir.join(crate::util::git::GitUtil::sanitize_url(&self.url)); + + if repo_dir.join(".hg").is_dir() { + // Update existing clone + self.hg_util.execute(&["pull"], Some(&repo_dir))?; + } else { + // Clone without checkout + let dir_str = repo_dir.to_string_lossy().to_string(); + self.hg_util + .execute(&["clone", "--noupdate", &self.url, &dir_str], None)?; + } + + self.repo_dir = Some(repo_dir.clone()); + + // Get default branch + let output = self.hg_util.execute( + &["log", "-r", "default", "--template", "{node|short}"], + Some(&repo_dir), + ); + self.root_identifier = match output { + Ok(o) if !o.stdout.trim().is_empty() => Some("default".to_string()), + _ => Some("tip".to_string()), + }; + + Ok(()) + } + + fn root_identifier(&self) -> &str { + self.root_identifier.as_deref().unwrap_or("default") + } + + 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()) + } + + 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()) + } + + 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)?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + 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) + } + } + + 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)) + } + } + + 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 + } + + fn cleanup(&mut self) -> Result<()> { + Ok(()) + } +} diff --git a/crates/mozart-vcs/src/driver/mod.rs b/crates/mozart-vcs/src/driver/mod.rs new file mode 100644 index 0000000..f8e26ef --- /dev/null +++ b/crates/mozart-vcs/src/driver/mod.rs @@ -0,0 +1,199 @@ +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 { + /// Path for caching VCS mirrors. + pub cache_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_dir: PathBuf::from(".cache/mozart/vcs"), + 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()], + } + } +} + +/// 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`. +pub trait VcsDriver { + /// Initialize the driver (e.g., clone mirror, fetch API metadata). + fn initialize(&mut self) -> Result<()>; + + /// The root identifier (default branch/trunk). + fn root_identifier(&self) -> &str; + + /// All branches as `name -> commit_hash`. + fn branches(&mut self) -> Result<&BTreeMap<String, String>>; + + /// All tags as `name -> commit_hash`. + fn tags(&mut self) -> Result<&BTreeMap<String, String>>; + + /// Get composer.json content parsed as JSON for a given identifier. + fn composer_information(&mut self, identifier: &str) -> Result<Option<serde_json::Value>>; + + /// Get raw file content at a given path and identifier. + fn file_content(&self, file: &str, identifier: &str) -> Result<Option<String>>; + + /// Get the change date for a given identifier (ISO 8601). + fn change_date(&self, identifier: &str) -> Result<Option<String>>; + + /// Get the dist reference for a given identifier. + 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.). + fn cleanup(&mut self) -> Result<()>; +} + +/// 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, +) -> Box<dyn VcsDriver> { + match driver_type { + DriverType::GitHub => Box::new(github::GitHubDriver::new(url, config)), + DriverType::GitLab => Box::new(gitlab::GitLabDriver::new(url, config)), + DriverType::Bitbucket => Box::new(bitbucket::BitbucketDriver::new(url, config)), + DriverType::Forgejo => Box::new(forgejo::ForgejoDriver::new(url, config)), + DriverType::Git => Box::new(git::GitDriver::new(url, config)), + DriverType::Svn => Box::new(svn::SvnDriver::new(url, config)), + DriverType::Hg => Box::new(hg::HgDriver::new(url, config)), + } +} diff --git a/crates/mozart-vcs/src/driver/svn.rs b/crates/mozart-vcs/src/driver/svn.rs new file mode 100644 index 0000000..8b47f75 --- /dev/null +++ b/crates/mozart-vcs/src/driver/svn.rs @@ -0,0 +1,213 @@ +use std::collections::{BTreeMap, HashMap}; + +use anyhow::Result; +use regex::Regex; + +use crate::process::ProcessExecutor; +use crate::util::svn::SvnUtil; + +use super::{DistReference, DriverConfig, SourceReference, VcsDriver}; + +/// SVN VCS driver. +/// +/// Corresponds to Composer's `Repository\Vcs\SvnDriver`. +pub struct SvnDriver { + url: String, + base_url: String, + trunk_path: String, + branches_path: String, + tags_path: String, + root_identifier: Option<String>, + tags: Option<BTreeMap<String, String>>, + branches: Option<BTreeMap<String, String>>, + info_cache: HashMap<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: HashMap::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 { + 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") + } + + 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()) + } + + 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()) + } + + 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)?; + let value = content.and_then(|c| serde_json::from_str(&c).ok()); + self.info_cache + .insert(identifier.to_string(), value.clone()); + Ok(value) + } + + 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), + } + } + + 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), + } + } + + 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 + } + + fn cleanup(&mut self) -> Result<()> { + Ok(()) + } +} + +/// Extract an XML attribute value from a simple XML string. +fn extract_xml_attr(xml: &str, tag: &str, attr: &str) -> Option<String> { + let pattern = format!(r#"<{tag}\s[^>]*{attr}="([^"]*)"#); + let re = Regex::new(&pattern).ok()?; + re.captures(xml).map(|c| c[1].to_string()) +} + +/// Extract text content between XML tags. +fn extract_xml_content(xml: &str, tag: &str) -> Option<String> { + let pattern = format!(r"<{tag}>([^<]*)</{tag}>"); + let re = Regex::new(&pattern).ok()?; + re.captures(xml).map(|c| c[1].to_string()) +} diff --git a/crates/mozart-vcs/src/lib.rs b/crates/mozart-vcs/src/lib.rs new file mode 100644 index 0000000..11db58d --- /dev/null +++ b/crates/mozart-vcs/src/lib.rs @@ -0,0 +1,5 @@ +pub mod downloader; +pub mod driver; +pub mod process; +pub mod repository; +pub mod util; diff --git a/crates/mozart-vcs/src/process.rs b/crates/mozart-vcs/src/process.rs new file mode 100644 index 0000000..91741a8 --- /dev/null +++ b/crates/mozart-vcs/src/process.rs @@ -0,0 +1,142 @@ +use std::collections::HashMap; +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: HashMap<String, Option<String>>, +} + +impl Default for ProcessExecutor { + fn default() -> Self { + Self::new() + } +} + +impl ProcessExecutor { + pub fn new() -> Self { + Self { + timeout: None, + env_overrides: HashMap::new(), + } + } + + pub fn with_timeout(secs: u64) -> Self { + Self { + timeout: Some(Duration::from_secs(secs)), + env_overrides: HashMap::new(), + } + } + + /// Set an environment variable override for all subsequent executions. + pub fn set_env(&mut self, key: impl Into<String>, value: impl Into<String>) { + self.env_overrides.insert(key.into(), Some(value.into())); + } + + /// Remove an environment variable for all subsequent executions. + pub fn remove_env(&mut self, key: impl Into<String>) { + self.env_overrides.insert(key.into(), None); + } + + /// Execute a command. Does not error on non-zero exit status. + pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> { + if args.is_empty() { + bail!("No command specified"); + } + + let mut cmd = Command::new(args[0]); + if args.len() > 1 { + cmd.args(&args[1..]); + } + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + + for (key, value) in &self.env_overrides { + match value { + Some(v) => { + cmd.env(key, v); + } + None => { + cmd.env_remove(key); + } + } + } + + if let Some(timeout) = self.timeout { + let mut child = cmd + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + + let start = Instant::now(); + loop { + match child.try_wait()? { + Some(status) => { + let mut stdout = String::new(); + let mut stderr = String::new(); + if let Some(ref mut out) = child.stdout { + std::io::Read::read_to_string(out, &mut stdout)?; + } + if let Some(ref mut err) = child.stderr { + std::io::Read::read_to_string(err, &mut stderr)?; + } + return Ok(ProcessOutput { + status: status.code().unwrap_or(-1), + stdout, + stderr, + }); + } + None => { + if start.elapsed() > timeout { + let _ = child.kill(); + bail!("Process timed out after {} seconds", timeout.as_secs()); + } + std::thread::sleep(Duration::from_millis(50)); + } + } + } + } else { + let output = cmd.output()?; + Ok(ProcessOutput { + status: output.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } + } + + /// Execute a command, returning an error if the exit status is non-zero. + pub fn execute_checked(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> { + let output = self.execute(args, cwd)?; + if output.status != 0 { + bail!( + "Command `{}` failed with exit code {}\nstdout: {}\nstderr: {}", + args.join(" "), + output.status, + output.stdout.trim(), + output.stderr.trim(), + ); + } + Ok(output) + } + + /// Split output into non-empty lines. + pub fn split_lines(output: &str) -> Vec<&str> { + output.lines().filter(|l| !l.is_empty()).collect() + } +} diff --git a/crates/mozart-vcs/src/repository.rs b/crates/mozart-vcs/src/repository.rs new file mode 100644 index 0000000..14f2ceb --- /dev/null +++ b/crates/mozart-vcs/src/repository.rs @@ -0,0 +1,206 @@ +use anyhow::{Result, bail}; + +use crate::driver::{ + DistReference, DriverConfig, DriverType, SourceReference, create_driver, detect_driver, +}; + +/// A single package version discovered from a VCS repository. +#[derive(Debug, Clone)] +pub struct VcsPackageVersion { + /// Package name (from composer.json). + pub name: String, + /// Version string (e.g., "1.2.3" for tags, "dev-main" for branches). + pub version: String, + /// Normalized version for comparison. + pub version_normalized: String, + /// Full composer.json data as JSON. + pub composer_json: serde_json::Value, + /// Source reference (VCS checkout info). + pub source: SourceReference, + /// Dist reference (archive download, if available). + pub dist: Option<DistReference>, + /// Whether this is the default branch version. + pub is_default_branch: bool, + /// Release date (ISO 8601). + pub time: Option<String>, +} + +/// Repository that scans a VCS URL for package versions. +/// +/// Corresponds to Composer's `Repository\VcsRepository`. +pub struct VcsRepository { + url: String, + driver_type: Option<DriverType>, + config: DriverConfig, +} + +impl VcsRepository { + pub fn new(url: String, repo_type: Option<&str>, config: DriverConfig) -> Self { + let driver_type = detect_driver(&url, repo_type, &config); + Self { + url, + driver_type, + config, + } + } + + /// Scan the VCS repository for all package versions. + /// + /// 1. Detects the driver type and initializes it + /// 2. Reads composer.json from the root to get the package name + /// 3. Scans tags → version releases + /// 4. Scans branches → dev versions + pub 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()?; + + // Get package name from root composer.json + let root_id = driver.root_identifier().to_string(); + let root_info = driver.composer_information(&root_id)?; + 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()?.clone(); + for (tag_name, tag_hash) in &tags { + if let Some(version) = self.tag_to_version(tag_name) { + match driver.composer_information(tag_hash) { + Ok(Some(info)) => { + let time = driver.change_date(tag_hash).unwrap_or(None); + let source = driver.source(tag_hash); + let dist = driver.dist(tag_hash).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()?.clone(); + let default_branch = driver.root_identifier().to_string(); + for (branch_name, branch_hash) in &branches { + match driver.composer_information(branch_hash) { + Ok(Some(info)) => { + if info["name"].as_str() != Some(&package_name) { + continue; + } + + let time = driver.change_date(branch_hash).unwrap_or(None); + let source = driver.source(branch_hash); + let dist = driver.dist(branch_hash).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()?; + Ok(versions) + } + + /// Convert a tag name to a version string. + /// Returns `None` if the tag doesn't look like a version. + fn tag_to_version(&self, tag: &str) -> Option<String> { + // Strip common prefixes + let version = tag + .strip_prefix('v') + .or_else(|| tag.strip_prefix("V")) + .or_else(|| tag.strip_prefix("release-")) + .or_else(|| tag.strip_prefix("release/")) + .unwrap_or(tag); + + // Basic semver-ish check + if version.is_empty() { + return None; + } + if version.chars().next()?.is_ascii_digit() { + Some(version.to_string()) + } else { + None + } + } + + /// Convert a branch name to a dev version string. + fn branch_to_version(&self, branch: &str) -> String { + // Numeric branches like "1.x", "2.0" become "1.x-dev", "2.0.x-dev" + if branch.chars().next().is_some_and(|c| c.is_ascii_digit()) { + let version = if branch.ends_with(".x") || branch.ends_with(".*") { + branch.to_string() + } else { + format!("{branch}.x") + }; + format!("{version}-dev") + } else { + format!("dev-{branch}") + } + } + + /// Normalize a version string. + fn normalize_version(&self, version: &str) -> String { + // Use mozart-semver for proper normalization if available, + // otherwise do a simple normalization + mozart_semver::Version::parse(version) + .map(|v| v.to_string()) + .unwrap_or_else(|_| version.to_string()) + } +} diff --git a/crates/mozart-vcs/src/util/git.rs b/crates/mozart-vcs/src/util/git.rs new file mode 100644 index 0000000..dce13b3 --- /dev/null +++ b/crates/mozart-vcs/src/util/git.rs @@ -0,0 +1,202 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Result, bail}; +use sha1::{Digest, Sha1}; + +use crate::process::{ProcessExecutor, ProcessOutput}; + +/// 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 directory name. + pub fn sanitize_url(url: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(url.as_bytes()); + let hash = hasher.finalize(); + format!("{:x}", hash) + } + + /// 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 + } +} diff --git a/crates/mozart-vcs/src/util/hg.rs b/crates/mozart-vcs/src/util/hg.rs new file mode 100644 index 0000000..7f5abcc --- /dev/null +++ b/crates/mozart-vcs/src/util/hg.rs @@ -0,0 +1,30 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::process::{ProcessExecutor, ProcessOutput}; + +/// Mercurial utility for command execution. +pub struct HgUtil { + process: ProcessExecutor, +} + +impl HgUtil { + pub fn new(process: ProcessExecutor) -> Self { + Self { process } + } + + /// Execute a Mercurial command. + pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> { + let mut full_args = vec!["hg"]; + full_args.extend_from_slice(args); + self.process.execute_checked(&full_args, cwd) + } + + /// Execute a Mercurial command, not erroring on non-zero exit. + pub fn execute_unchecked(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> { + let mut full_args = vec!["hg"]; + full_args.extend_from_slice(args); + self.process.execute(&full_args, cwd) + } +} diff --git a/crates/mozart-vcs/src/util/mod.rs b/crates/mozart-vcs/src/util/mod.rs new file mode 100644 index 0000000..b2c35fc --- /dev/null +++ b/crates/mozart-vcs/src/util/mod.rs @@ -0,0 +1,3 @@ +pub mod git; +pub mod hg; +pub mod svn; diff --git a/crates/mozart-vcs/src/util/svn.rs b/crates/mozart-vcs/src/util/svn.rs new file mode 100644 index 0000000..e9a6813 --- /dev/null +++ b/crates/mozart-vcs/src/util/svn.rs @@ -0,0 +1,91 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::process::{ProcessExecutor, ProcessOutput}; + +/// SVN credentials for authenticated operations. +#[derive(Debug, Clone)] +pub struct SvnCredentials { + pub username: String, + pub password: String, +} + +/// SVN utility for command execution with credential handling. +pub struct SvnUtil { + process: ProcessExecutor, +} + +impl SvnUtil { + pub fn new(process: ProcessExecutor) -> Self { + Self { process } + } + + /// Execute an SVN command with `--non-interactive`. + pub fn execute(&self, args: &[&str], cwd: Option<&Path>) -> Result<ProcessOutput> { + let mut full_args = vec!["svn"]; + full_args.extend_from_slice(args); + full_args.push("--non-interactive"); + self.process.execute_checked(&full_args, cwd) + } + + /// Execute an SVN command with optional credentials, retrying on auth failure. + pub fn execute_with_credentials( + &self, + args: &[&str], + creds: Option<&SvnCredentials>, + cwd: Option<&Path>, + ) -> Result<ProcessOutput> { + let mut full_args = vec!["svn"]; + full_args.extend_from_slice(args); + full_args.push("--non-interactive"); + + let cred_args: Vec<String>; + if let Some(c) = creds { + cred_args = vec![ + "--username".to_string(), + c.username.clone(), + "--password".to_string(), + c.password.clone(), + ]; + for arg in &cred_args { + full_args.push(arg); + } + } + + let full_args_refs: Vec<&str> = full_args.iter().map(|s| &**s).collect(); + + // Retry up to 5 times on auth failure + let max_retries = 5; + let mut last_output = None; + for _ in 0..max_retries { + let output = self.process.execute(&full_args_refs, cwd)?; + if output.status == 0 { + return Ok(output); + } + // Check if it's an auth error (SVN exit code or stderr hint) + if !output.stderr.contains("authorization failed") + && !output.stderr.contains("Could not authenticate") + && !output.stderr.contains("Authentication failed") + { + // Not an auth error, return immediately + last_output = Some(output); + break; + } + last_output = Some(output); + } + + match last_output { + Some(output) if output.status != 0 => { + anyhow::bail!( + "SVN command `{}` failed with exit code {}\nstderr: {}", + full_args_refs.join(" "), + output.status, + output.stderr.trim(), + ); + } + Some(output) => Ok(output), + None => anyhow::bail!("SVN command failed with no output"), + } + } +} diff --git a/crates/mozart-vcs/tests/git_driver_test.rs b/crates/mozart-vcs/tests/git_driver_test.rs new file mode 100644 index 0000000..1fafc7c --- /dev/null +++ b/crates/mozart-vcs/tests/git_driver_test.rs @@ -0,0 +1,272 @@ +use std::path::Path; +use std::process::Command; + +use tempfile::TempDir; + +use mozart_vcs::downloader::VcsDownloader; +use mozart_vcs::downloader::git::GitDownloader; +use mozart_vcs::driver::git::GitDriver; +use mozart_vcs::driver::{DriverConfig, VcsDriver}; +use mozart_vcs::process::ProcessExecutor; +use mozart_vcs::util::git::GitUtil; + +fn has_git() -> bool { + Command::new("git").arg("--version").output().is_ok() +} + +fn create_test_repo(dir: &Path) { + let run = |args: &[&str]| { + let output = Command::new(args[0]) + .args(&args[1..]) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "Test") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "Test") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .unwrap(); + assert!( + output.status.success(), + "Command failed: {:?}\nstderr: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + }; + + run(&["git", "init", "-b", "main"]); + run(&["git", "config", "user.email", "test@test.com"]); + run(&["git", "config", "user.name", "Test"]); + + // Create composer.json + std::fs::write( + dir.join("composer.json"), + r#"{"name": "test/package", "description": "Test package"}"#, + ) + .unwrap(); + + run(&["git", "add", "."]); + run(&["git", "commit", "-m", "Initial commit"]); + + // Create a tag + run(&["git", "tag", "v1.0.0"]); + + // Create another commit on main + std::fs::write(dir.join("README.md"), "# Test").unwrap(); + run(&["git", "add", "."]); + run(&["git", "commit", "-m", "Add readme"]); + + // Create a second tag + run(&["git", "tag", "v1.1.0"]); + + // Create a feature branch + run(&["git", "checkout", "-b", "feature/test"]); + std::fs::write(dir.join("feature.txt"), "feature").unwrap(); + run(&["git", "add", "."]); + run(&["git", "commit", "-m", "Feature commit"]); + run(&["git", "checkout", "main"]); +} + +#[test] +fn test_git_driver_local_repo() { + if !has_git() { + eprintln!("Skipping test: git not available"); + return; + } + + let repo_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + create_test_repo(repo_dir.path()); + + let config = DriverConfig { + cache_dir: cache_dir.path().to_path_buf(), + ..DriverConfig::default() + }; + + let mut driver = GitDriver::new(repo_dir.path().to_str().unwrap(), config); + + driver.initialize().unwrap(); + assert_eq!(driver.root_identifier(), "main"); + + // Check tags + let tags = driver.tags().unwrap().clone(); + assert!( + tags.contains_key("v1.0.0"), + "Missing tag v1.0.0: {:?}", + tags + ); + assert!( + tags.contains_key("v1.1.0"), + "Missing tag v1.1.0: {:?}", + tags + ); + + // Check branches + let branches = driver.branches().unwrap().clone(); + assert!( + branches.contains_key("main"), + "Missing branch main: {:?}", + branches + ); + assert!( + branches.contains_key("feature/test"), + "Missing branch feature/test: {:?}", + branches, + ); + + // Read composer.json + let tag_hash = &tags["v1.0.0"]; + let info = driver.composer_information(tag_hash).unwrap(); + assert!(info.is_some()); + let info = info.unwrap(); + assert_eq!(info["name"].as_str(), Some("test/package")); + + // Read file content + let content = driver.file_content("composer.json", tag_hash).unwrap(); + assert!(content.is_some()); + assert!(content.unwrap().contains("test/package")); + + // Change date + let date = driver.change_date(tag_hash).unwrap(); + assert!(date.is_some()); + + // Source reference + let source = driver.source(tag_hash); + assert_eq!(source.source_type, "git"); + + driver.cleanup().unwrap(); +} + +#[test] +fn test_git_downloader() { + if !has_git() { + eprintln!("Skipping test: git not available"); + return; + } + + let repo_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + let install_dir = TempDir::new().unwrap(); + create_test_repo(repo_dir.path()); + + let process = ProcessExecutor::new(); + let git_util = GitUtil::new(process, cache_dir.path().join("git")); + let downloader = GitDownloader::new(git_util); + + let url = repo_dir.path().to_str().unwrap(); + let target = install_dir.path().join("test-package"); + + // Download (sync mirror) + downloader.download(url, "v1.0.0", &target).unwrap(); + + // Install + downloader.install(url, "v1.0.0", &target).unwrap(); + assert!(target.join("composer.json").exists()); + + // Check no local changes + let changes = downloader.local_changes(&target).unwrap(); + assert!(changes.is_none(), "Expected no changes, got: {:?}", changes); + + // Make a local change and detect it + std::fs::write(target.join("local_change.txt"), "change").unwrap(); + let changes = downloader.local_changes(&target).unwrap(); + assert!(changes.is_some()); + assert!(changes.unwrap().contains("local_change.txt")); + + // Commit logs + let logs = downloader.commit_logs("v1.0.0", "v1.1.0", &target).unwrap(); + assert!(logs.contains("Add readme")); + + // Remove + downloader.remove(&target).unwrap(); + assert!(!target.exists()); +} + +#[test] +fn test_detect_driver() { + use mozart_vcs::driver::{DriverType, detect_driver}; + + let config = DriverConfig::default(); + + assert_eq!( + detect_driver("https://github.com/owner/repo", None, &config), + Some(DriverType::GitHub), + ); + assert_eq!( + detect_driver("git@github.com:owner/repo.git", None, &config), + Some(DriverType::GitHub), + ); + assert_eq!( + detect_driver("https://gitlab.com/owner/repo", None, &config), + Some(DriverType::GitLab), + ); + assert_eq!( + detect_driver("https://bitbucket.org/owner/repo", None, &config), + Some(DriverType::Bitbucket), + ); + assert_eq!( + detect_driver("https://codeberg.org/owner/repo", None, &config), + Some(DriverType::Forgejo), + ); + assert_eq!( + detect_driver("git://example.com/repo.git", None, &config), + Some(DriverType::Git), + ); + assert_eq!( + detect_driver("svn://example.com/repo", None, &config), + Some(DriverType::Svn), + ); + + // Forced type + assert_eq!( + detect_driver("https://example.com/repo", Some("git"), &config), + Some(DriverType::Git), + ); +} + +#[test] +fn test_vcs_repository_scan() { + if !has_git() { + eprintln!("Skipping test: git not available"); + return; + } + + let repo_dir = TempDir::new().unwrap(); + let cache_dir = TempDir::new().unwrap(); + create_test_repo(repo_dir.path()); + + let config = DriverConfig { + cache_dir: cache_dir.path().to_path_buf(), + ..DriverConfig::default() + }; + + let repo = mozart_vcs::repository::VcsRepository::new( + repo_dir.path().to_str().unwrap().to_string(), + None, + config, + ); + + let versions = repo.scan().unwrap(); + assert!(!versions.is_empty(), "No versions found"); + + // Should find tag versions + let tag_versions: Vec<_> = versions + .iter() + .filter(|v| !v.version.starts_with("dev-")) + .collect(); + assert!(!tag_versions.is_empty(), "No tag versions found"); + + // Should find branch versions + let dev_versions: Vec<_> = versions + .iter() + .filter(|v| v.version.starts_with("dev-")) + .collect(); + assert!(!dev_versions.is_empty(), "No dev versions found"); + + // Check default branch flag + let default_versions: Vec<_> = versions.iter().filter(|v| v.is_default_branch).collect(); + assert_eq!( + default_versions.len(), + 1, + "Expected exactly one default branch version" + ); +} diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml index 915273b..3f1cd33 100644 --- a/crates/mozart/Cargo.toml +++ b/crates/mozart/Cargo.toml @@ -9,6 +9,7 @@ mozart-autoload.workspace = true mozart-core.workspace = true mozart-registry.workspace = true mozart-semver.workspace = true +mozart-vcs.workspace = true anyhow.workspace = true clap.workspace = true clap_complete.workspace = true diff --git a/crates/mozart/src/commands/create_project.rs b/crates/mozart/src/commands/create_project.rs index 6a5b815..2a8ce4f 100644 --- a/crates/mozart/src/commands/create_project.rs +++ b/crates/mozart/src/commands/create_project.rs @@ -411,6 +411,7 @@ pub async fn execute( ignore_platform_req_list: args.ignore_platform_req.clone(), repo_cache: None, temporary_constraints: HashMap::new(), + repositories: raw.repositories.clone(), }; console.info("Resolving dependencies..."); @@ -502,6 +503,7 @@ pub async fn execute( apcu_autoloader, apcu_autoloader_prefix: None, download_only: false, + prefer_source: args.prefer_source, }, ) .await?; diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs index 24c79b3..bf35788 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -117,6 +117,8 @@ pub struct InstallConfig { pub apcu_autoloader_prefix: Option<String>, /// Only download packages, skip autoloader generation and installed.json write. pub download_only: bool, + /// Prefer installing from VCS source rather than dist archives. + pub prefer_source: bool, } impl Default for InstallConfig { @@ -133,6 +135,7 @@ impl Default for InstallConfig { apcu_autoloader: false, apcu_autoloader_prefix: None, download_only: false, + prefer_source: false, } } } @@ -300,20 +303,51 @@ fn make_progress(show: bool, pkg_name: &str, version: &str) -> downloader::Downl downloader::DownloadProgress::new(show, format!("{pkg_name} ({version})")) } -/// Install packages from a lock file into vendor/. -/// -/// Used by both the `install` and `update` commands. -/// -/// This function: -/// 1. Determines which packages to install (prod + optionally dev) -/// 2. Warns about platform requirements (unless ignored) -/// 3. Reads currently installed packages -/// 4. Computes install/update/skip/removal operations -/// 5. Prints a summary -/// 6. Executes downloads with optional progress bars (unless dry_run) -/// 7. Writes vendor/composer/installed.json -/// 8. Cleans up empty vendor directories -/// 9. Generates the autoloader (unless no_autoloader) +/// Install a package from VCS source (git/svn/hg). +fn install_from_source( + source_type: &str, + url: &str, + reference: &str, + vendor_dir: &Path, + package_name: &str, +) -> anyhow::Result<()> { + let target = vendor_dir.join(package_name); + if target.exists() { + std::fs::remove_dir_all(&target)?; + } + + match source_type { + "git" => { + let process = mozart_vcs::process::ProcessExecutor::new(); + let git_util = + mozart_vcs::util::git::GitUtil::new(process, vendor_dir.join(".cache").join("git")); + let downloader = mozart_vcs::downloader::git::GitDownloader::new(git_util); + use mozart_vcs::downloader::VcsDownloader; + downloader.download(url, reference, &target)?; + downloader.install(url, reference, &target)?; + } + "svn" => { + let process = mozart_vcs::process::ProcessExecutor::new(); + let svn_util = mozart_vcs::util::svn::SvnUtil::new(process); + let downloader = mozart_vcs::downloader::svn::SvnDownloader::new(svn_util); + use mozart_vcs::downloader::VcsDownloader; + downloader.install(url, reference, &target)?; + } + "hg" => { + let process = mozart_vcs::process::ProcessExecutor::new(); + let hg_util = mozart_vcs::util::hg::HgUtil::new(process); + let downloader = mozart_vcs::downloader::hg::HgDownloader::new(hg_util); + use mozart_vcs::downloader::VcsDownloader; + downloader.install(url, reference, &target)?; + } + _ => { + anyhow::bail!("Unsupported source type for VCS install: {}", source_type); + } + } + + Ok(()) +} + pub async fn install_from_lock( lock: &lockfile::LockFile, working_dir: &Path, @@ -405,11 +439,32 @@ pub async fn install_from_lock( } } + // Try source install if --prefer-source and source info is available + if config.prefer_source + && let Some(source) = &pkg.source + { + install_from_source( + &source.source_type, + &source.url, + source.reference.as_deref().unwrap_or("HEAD"), + vendor_dir, + &pkg.name, + )?; + continue; + } + let dist = pkg.dist.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Package {} has no dist information — source installs are not yet supported", - pkg.name - ) + if pkg.source.is_some() { + anyhow::anyhow!( + "Package {} has no dist information. Use --prefer-source to install from VCS.", + pkg.name, + ) + } else { + anyhow::anyhow!( + "Package {} has no dist or source information", + pkg.name, + ) + } })?; let mut progress = make_progress(!config.no_progress, &pkg.name, &pkg.version); @@ -604,18 +659,13 @@ pub async fn execute( } } - // Step 5: Warn about prefer-source (not yet supported) + // Step 5: Determine if prefer-source is enabled let prefer_source = args.prefer_source || args .prefer_install .as_deref() .map(|s| s.eq_ignore_ascii_case("source")) .unwrap_or(false); - if prefer_source { - console.info(&console_format!( - "<warning>Warning: Source installs are not yet supported. Falling back to dist.</warning>" - )); - } // Step 6: Determine dev mode and vendor directory let dev_mode = !args.no_dev; @@ -638,6 +688,7 @@ pub async fn execute( apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some(), apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(), download_only: args.download_only, + prefer_source, }, ) .await diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index 88fa4da..6d3b5e2 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -251,6 +251,7 @@ pub async fn execute( ignore_platform_req_list: args.ignore_platform_req.clone(), repo_cache: None, temporary_constraints: HashMap::new(), + repositories: raw.repositories.clone(), }; // Print header messages @@ -438,6 +439,7 @@ pub async fn execute( apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some(), apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(), download_only: false, + prefer_source: false, }, ) .await?; @@ -497,6 +499,7 @@ async fn remove_unused( ignore_platform_req_list: args.ignore_platform_req.clone(), repo_cache: None, temporary_constraints: HashMap::new(), + repositories: raw.repositories.clone(), }; console.info("Resolving dependencies to detect unused packages..."); @@ -577,6 +580,7 @@ async fn remove_unused( apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some(), apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(), download_only: false, + prefer_source: false, }, ) .await?; @@ -826,6 +830,7 @@ mod tests { ignore_platform_req_list: vec![], repo_cache: None, temporary_constraints: HashMap::new(), + repositories: vec![], }; let resolved = resolve(&request) .await @@ -862,6 +867,7 @@ mod tests { ignore_platform_req_list: vec![], repo_cache: None, temporary_constraints: HashMap::new(), + repositories: vec![], }; let resolved2 = resolve(&request2) .await diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index 4ea739d..15b5f1c 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -656,6 +656,7 @@ pub async fn execute( ignore_platform_req_list: args.ignore_platform_req.clone(), repo_cache: None, temporary_constraints: HashMap::new(), + repositories: raw.repositories.clone(), }; // Print header messages @@ -870,6 +871,7 @@ pub async fn execute( || config_apcu, apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(), download_only: false, + prefer_source: args.prefer_source, }, ) .await?; @@ -1028,6 +1030,7 @@ mod tests { ignore_platform_req_list: vec![], repo_cache: None, temporary_constraints: HashMap::new(), + repositories: vec![], }; let resolved = resolver::resolve(&request) @@ -1081,6 +1084,7 @@ mod tests { ignore_platform_req_list: vec![], repo_cache: None, temporary_constraints: HashMap::new(), + repositories: vec![], }; let resolved = resolver::resolve(&request) diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs index 06e6b22..c1901cd 100644 --- a/crates/mozart/src/commands/update.rs +++ b/crates/mozart/src/commands/update.rs @@ -835,6 +835,7 @@ pub async fn execute( ignore_platform_req_list: args.ignore_platform_req.clone(), repo_cache: None, temporary_constraints, + repositories: composer_json.repositories.clone(), }; // Step 6: Print header and run resolver @@ -1164,18 +1165,13 @@ pub async fn execute( // Step 12: Install packages (unless --no-install or --dry-run) if !args.no_install && !args.dry_run { - // Warn about prefer-source (not yet supported) + // Determine if prefer-source is enabled let prefer_source = args.prefer_source || args .prefer_install .as_deref() .map(|s| s.eq_ignore_ascii_case("source")) .unwrap_or(false); - if prefer_source { - console.info(&console_format!( - "<warning>Warning: Source installs are not yet supported. Falling back to dist.</warning>" - )); - } super::install::install_from_lock( &new_lock, @@ -1193,6 +1189,7 @@ pub async fn execute( apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some(), apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(), download_only: false, + prefer_source, }, ) .await?; @@ -1909,6 +1906,7 @@ mod tests { ignore_platform_req_list: vec![], repo_cache: None, temporary_constraints: HashMap::new(), + repositories: vec![], }; let resolved = resolve(&request).await.expect("Resolution should succeed"); |
