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