aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src/repository
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-10 20:01:21 +0900
committernsfisis <nsfisis@gmail.com>2026-05-10 20:01:21 +0900
commit24bb31c109332ae982b7091ffcd5183442ce6f6f (patch)
treece590a34053bd48506310c0a0c26395605973d45 /crates/mozart-core/src/repository
parentdd13f29a3535bf15bb2494da4c67b5e2c61bbda5 (diff)
downloadphp-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/repository')
-rw-r--r--crates/mozart-core/src/repository/vcs.rs284
-rw-r--r--crates/mozart-core/src/repository/vcs/forgejo_driver.rs285
-rw-r--r--crates/mozart-core/src/repository/vcs/fossil_driver.rs1
-rw-r--r--crates/mozart-core/src/repository/vcs/git_bitbucket_driver.rs276
-rw-r--r--crates/mozart-core/src/repository/vcs/git_driver.rs275
-rw-r--r--crates/mozart-core/src/repository/vcs/github_driver.rs314
-rw-r--r--crates/mozart-core/src/repository/vcs/gitlab_driver.rs300
-rw-r--r--crates/mozart-core/src/repository/vcs/hg_driver.rs203
-rw-r--r--crates/mozart-core/src/repository/vcs/perforce_driver.rs1
-rw-r--r--crates/mozart-core/src/repository/vcs/svn_driver.rs214
-rw-r--r--crates/mozart-core/src/repository/vcs/vcs_driver.rs1
-rw-r--r--crates/mozart-core/src/repository/vcs/vcs_driver_interface.rs43
-rw-r--r--crates/mozart-core/src/repository/vcs_bridge.rs6
13 files changed, 2200 insertions, 3 deletions
diff --git a/crates/mozart-core/src/repository/vcs.rs b/crates/mozart-core/src/repository/vcs.rs
new file mode 100644
index 0000000..88ed702
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs.rs
@@ -0,0 +1,284 @@
+mod forgejo_driver;
+mod fossil_driver;
+mod git_bitbucket_driver;
+mod git_driver;
+mod github_driver;
+mod gitlab_driver;
+mod hg_driver;
+mod perforce_driver;
+mod svn_driver;
+mod vcs_driver;
+mod vcs_driver_interface;
+
+pub use forgejo_driver::*;
+pub use fossil_driver::*;
+pub use git_bitbucket_driver::*;
+pub use git_driver::*;
+pub use github_driver::*;
+pub use gitlab_driver::*;
+pub use hg_driver::*;
+pub use perforce_driver::*;
+pub use svn_driver::*;
+pub use vcs_driver::*;
+pub use vcs_driver_interface::*;
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+use anyhow::Result;
+use serde::{Deserialize, Serialize};
+
+/// Reference to a source distribution.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SourceReference {
+ #[serde(rename = "type")]
+ pub source_type: String,
+ pub url: String,
+ pub reference: String,
+}
+
+/// Reference to a dist (archive) distribution.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct DistReference {
+ #[serde(rename = "type")]
+ pub dist_type: String,
+ pub url: String,
+ pub reference: String,
+ pub shasum: Option<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,
+}
+
+/// Enum-dispatched VCS driver.
+///
+/// Wraps all concrete driver types to allow static dispatch with async trait methods.
+pub enum AnyVcsDriver {
+ GitHub(GitHubDriver),
+ GitLab(GitLabDriver),
+ Bitbucket(GitBitbucketDriver),
+ Forgejo(ForgejoDriver),
+ Git(GitDriver),
+ Svn(SvnDriver),
+ Hg(HgDriver),
+}
+
+macro_rules! dispatch {
+ ($self:expr, $method:ident $(, $arg:expr)*) => {
+ match $self {
+ AnyVcsDriver::GitHub(d) => d.$method($($arg),*),
+ AnyVcsDriver::GitLab(d) => d.$method($($arg),*),
+ AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*),
+ AnyVcsDriver::Forgejo(d) => d.$method($($arg),*),
+ AnyVcsDriver::Git(d) => d.$method($($arg),*),
+ AnyVcsDriver::Svn(d) => d.$method($($arg),*),
+ AnyVcsDriver::Hg(d) => d.$method($($arg),*),
+ }
+ };
+}
+
+macro_rules! dispatch_async {
+ ($self:expr, $method:ident $(, $arg:expr)*) => {
+ match $self {
+ AnyVcsDriver::GitHub(d) => d.$method($($arg),*).await,
+ AnyVcsDriver::GitLab(d) => d.$method($($arg),*).await,
+ AnyVcsDriver::Bitbucket(d) => d.$method($($arg),*).await,
+ AnyVcsDriver::Forgejo(d) => d.$method($($arg),*).await,
+ AnyVcsDriver::Git(d) => d.$method($($arg),*).await,
+ AnyVcsDriver::Svn(d) => d.$method($($arg),*).await,
+ AnyVcsDriver::Hg(d) => d.$method($($arg),*).await,
+ }
+ };
+}
+
+impl AnyVcsDriver {
+ pub async fn initialize(&mut self) -> Result<()> {
+ dispatch_async!(self, initialize)
+ }
+
+ pub fn root_identifier(&self) -> &str {
+ dispatch!(self, root_identifier)
+ }
+
+ pub async fn branches(&mut self) -> Result<&BTreeMap<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 GitHubDriver::supports(url) {
+ return Some(DriverType::GitHub);
+ }
+
+ // GitLab
+ if GitLabDriver::supports(url, &config.gitlab_domains) {
+ return Some(DriverType::GitLab);
+ }
+
+ // Bitbucket
+ if GitBitbucketDriver::supports(url) {
+ return Some(DriverType::Bitbucket);
+ }
+
+ // Forgejo
+ if ForgejoDriver::supports(url, &config.forgejo_domains) {
+ return Some(DriverType::Forgejo);
+ }
+
+ // Git
+ if GitDriver::supports(url) {
+ return Some(DriverType::Git);
+ }
+
+ // Hg
+ if HgDriver::supports(url) {
+ return Some(DriverType::Hg);
+ }
+
+ // SVN
+ if url_lower.contains("svn") || SvnDriver::supports(url) {
+ return Some(DriverType::Svn);
+ }
+
+ // Default to git for generic URLs
+ if url.starts_with("http://") || url.starts_with("https://") {
+ return Some(DriverType::Git);
+ }
+
+ None
+}
+
+/// Create a driver instance for the given URL and type.
+pub fn create_driver(url: &str, driver_type: DriverType, config: DriverConfig) -> AnyVcsDriver {
+ match driver_type {
+ DriverType::GitHub => AnyVcsDriver::GitHub(GitHubDriver::new(url, config)),
+ DriverType::GitLab => AnyVcsDriver::GitLab(GitLabDriver::new(url, config)),
+ DriverType::Bitbucket => AnyVcsDriver::Bitbucket(GitBitbucketDriver::new(url, config)),
+ DriverType::Forgejo => AnyVcsDriver::Forgejo(ForgejoDriver::new(url, config)),
+ DriverType::Git => AnyVcsDriver::Git(GitDriver::new(url, config)),
+ DriverType::Svn => AnyVcsDriver::Svn(SvnDriver::new(url, config)),
+ DriverType::Hg => AnyVcsDriver::Hg(HgDriver::new(url, config)),
+ }
+}
diff --git a/crates/mozart-core/src/repository/vcs/forgejo_driver.rs b/crates/mozart-core/src/repository/vcs/forgejo_driver.rs
new file mode 100644
index 0000000..699959b
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/forgejo_driver.rs
@@ -0,0 +1,285 @@
+use crate::repository::vcs::{
+ DistReference, DriverConfig, GitDriver, SourceReference, VcsDriverInterface,
+ base64_decode_content,
+};
+use anyhow::{Result, bail};
+use indexmap::IndexMap;
+use regex::Regex;
+use reqwest::Client;
+use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
+use std::collections::BTreeMap;
+
+/// Forgejo/Gitea VCS driver using the REST API v1.
+///
+/// Supports self-hosted instances (Codeberg, etc.).
+pub struct ForgejoDriver {
+ owner: String,
+ repo: String,
+ host: String,
+ scheme: String,
+ url: String,
+ root_identifier: Option<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 VcsDriverInterface for ForgejoDriver {
+ async fn initialize(&mut self) -> Result<()> {
+ match self.api_get("").await {
+ Ok(data) => {
+ let default_branch = data["default_branch"]
+ .as_str()
+ .unwrap_or("main")
+ .to_string();
+ self.root_identifier = Some(default_branch);
+ }
+ Err(_) => {
+ self.api_failed = true;
+ let driver = self.use_git_fallback().await?;
+ self.root_identifier = Some(driver.root_identifier().to_string());
+ }
+ }
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("main")
+ }
+
+ async fn branches(&mut self) -> Result<&BTreeMap<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 = 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/repository/vcs/fossil_driver.rs b/crates/mozart-core/src/repository/vcs/fossil_driver.rs
new file mode 100644
index 0000000..8d45a43
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/fossil_driver.rs
@@ -0,0 +1 @@
+pub struct FossilDriver;
diff --git a/crates/mozart-core/src/repository/vcs/git_bitbucket_driver.rs b/crates/mozart-core/src/repository/vcs/git_bitbucket_driver.rs
new file mode 100644
index 0000000..0ec1dfd
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/git_bitbucket_driver.rs
@@ -0,0 +1,276 @@
+use crate::repository::vcs::{
+ DistReference, DriverConfig, GitDriver, SourceReference, VcsDriverInterface,
+};
+use anyhow::{Result, bail};
+use indexmap::IndexMap;
+use regex::Regex;
+use reqwest::Client;
+use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
+use std::collections::BTreeMap;
+
+/// Bitbucket VCS driver using the REST API 2.0.
+pub struct GitBitbucketDriver {
+ owner: String,
+ repo: String,
+ url: String,
+ root_identifier: Option<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 GitBitbucketDriver {
+ pub fn new(url: &str, config: DriverConfig) -> Self {
+ let (owner, repo) = Self::parse_url(url).unwrap_or_default();
+ Self {
+ owner,
+ repo,
+ url: url.to_string(),
+ root_identifier: None,
+ tags: None,
+ branches: None,
+ info_cache: IndexMap::new(),
+ git_driver: None,
+ http_client: crate::http::default_client(),
+ config,
+ api_failed: false,
+ vcs_type: "git".to_string(),
+ }
+ }
+
+ pub fn supports(url: &str) -> bool {
+ let url_lower = url.to_lowercase();
+ url_lower.contains("bitbucket.org")
+ }
+
+ fn parse_url(url: &str) -> Option<(String, String)> {
+ let re =
+ Regex::new(r"bitbucket\.org[:/]([^/]+)/([^/.\s]+?)(?:\.git)?(?:[/#?].*)?$").ok()?;
+ let caps = re.captures(url)?;
+ Some((caps[1].to_string(), caps[2].to_string()))
+ }
+
+ fn api_url(&self, path: &str) -> String {
+ format!(
+ "https://api.bitbucket.org/2.0/repositories/{}/{}{}",
+ self.owner, self.repo, path,
+ )
+ }
+
+ #[tracing::instrument(skip(self))]
+ async fn api_get(&self, path: &str) -> Result<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 VcsDriverInterface for GitBitbucketDriver {
+ async fn initialize(&mut self) -> Result<()> {
+ match self.api_get("").await {
+ Ok(data) => {
+ if let Some(scm) = data["scm"].as_str() {
+ self.vcs_type = scm.to_string();
+ }
+ let default_branch = data["mainbranch"]["name"]
+ .as_str()
+ .unwrap_or("main")
+ .to_string();
+ self.root_identifier = Some(default_branch);
+ }
+ Err(_) => {
+ self.api_failed = true;
+ let driver = self.use_git_fallback().await?;
+ self.root_identifier = Some(driver.root_identifier().to_string());
+ }
+ }
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("main")
+ }
+
+ async fn branches(&mut self) -> Result<&BTreeMap<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/repository/vcs/git_driver.rs b/crates/mozart-core/src/repository/vcs/git_driver.rs
new file mode 100644
index 0000000..d2f9c04
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/git_driver.rs
@@ -0,0 +1,275 @@
+use crate::repository::vcs::{DistReference, DriverConfig, SourceReference, VcsDriverInterface};
+use crate::vcs::process::ProcessExecutor;
+use crate::vcs::util::git::GitUtil;
+use anyhow::Result;
+use indexmap::IndexMap;
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+/// Git VCS driver.
+///
+/// Corresponds to Composer's `Repository\Vcs\GitDriver`.
+pub struct GitDriver {
+ url: String,
+ repo_dir: Option<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 VcsDriverInterface for GitDriver {
+ async fn initialize(&mut self) -> Result<()> {
+ if self.is_local {
+ // Local repo: use directly (or its .git subdir)
+ let path = Path::new(&self.url);
+ if path.join(".git").is_dir() {
+ self.repo_dir = Some(path.join(".git"));
+ } else {
+ self.repo_dir = Some(path.to_path_buf());
+ }
+ } else {
+ // Remote repo: sync mirror
+ let mirror_dir = self.git_util.sync_mirror(&self.url)?;
+ self.repo_dir = Some(mirror_dir);
+ }
+
+ // Determine root identifier (default branch)
+ let repo_dir = self.repo_dir.clone().unwrap();
+ if let Ok(Some(branch)) = self.git_util.get_default_branch(&repo_dir) {
+ self.root_identifier = Some(branch);
+ } else {
+ // Fallback: try common branch names
+ let process = ProcessExecutor::new();
+ for name in &["main", "master"] {
+ let output =
+ process.execute(&["git", "rev-parse", "--verify", name], Some(&repo_dir))?;
+ if output.status == 0 {
+ self.root_identifier = Some(name.to_string());
+ break;
+ }
+ }
+ }
+
+ if self.root_identifier.is_none() {
+ self.root_identifier = Some("master".to_string());
+ }
+
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("master")
+ }
+
+ async fn branches(&mut self) -> Result<&BTreeMap<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/repository/vcs/github_driver.rs b/crates/mozart-core/src/repository/vcs/github_driver.rs
new file mode 100644
index 0000000..7a8bc98
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/github_driver.rs
@@ -0,0 +1,314 @@
+use crate::repository::vcs::{
+ DistReference, DriverConfig, GitDriver, SourceReference, VcsDriverInterface,
+};
+use anyhow::{Result, bail};
+use indexmap::IndexMap;
+use regex::Regex;
+use reqwest::Client;
+use reqwest::header::{ACCEPT, AUTHORIZATION, USER_AGENT};
+use std::collections::BTreeMap;
+
+/// GitHub VCS driver using the REST API v3.
+///
+/// Falls back to `GitDriver` when API access fails.
+pub struct GitHubDriver {
+ owner: String,
+ repo: String,
+ url: String,
+ root_identifier: Option<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 VcsDriverInterface for GitHubDriver {
+ async fn initialize(&mut self) -> Result<()> {
+ // Try to fetch repo data from API
+ match self.api_get("").await {
+ Ok(data) => {
+ let default_branch = data["default_branch"]
+ .as_str()
+ .unwrap_or("main")
+ .to_string();
+ self.root_identifier = Some(default_branch);
+ self.repo_data = Some(data);
+ }
+ Err(_) => {
+ self.api_failed = true;
+ let driver = self.use_git_fallback().await?;
+ self.root_identifier = Some(driver.root_identifier().to_string());
+ }
+ }
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("main")
+ }
+
+ async fn branches(&mut self) -> Result<&BTreeMap<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/repository/vcs/gitlab_driver.rs b/crates/mozart-core/src/repository/vcs/gitlab_driver.rs
new file mode 100644
index 0000000..6b5e7af
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/gitlab_driver.rs
@@ -0,0 +1,300 @@
+use crate::repository::vcs::{
+ DistReference, DriverConfig, GitDriver, SourceReference, VcsDriverInterface,
+};
+use anyhow::{Result, bail};
+use indexmap::IndexMap;
+use regex::Regex;
+use reqwest::Client;
+use reqwest::header::{ACCEPT, USER_AGENT};
+use std::collections::BTreeMap;
+
+/// GitLab VCS driver using the REST API v4.
+///
+/// Supports self-hosted GitLab instances.
+pub struct GitLabDriver {
+ owner: String,
+ repo: String,
+ host: String,
+ scheme: String,
+ url: String,
+ project_id: Option<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 VcsDriverInterface for GitLabDriver {
+ async fn initialize(&mut self) -> Result<()> {
+ match self.api_get("").await {
+ Ok(data) => {
+ if let Some(id) = data["id"].as_u64() {
+ self.project_id = Some(id.to_string());
+ }
+ let default_branch = data["default_branch"]
+ .as_str()
+ .unwrap_or("main")
+ .to_string();
+ self.root_identifier = Some(default_branch);
+ }
+ Err(_) => {
+ self.api_failed = true;
+ let driver = self.use_git_fallback().await?;
+ self.root_identifier = Some(driver.root_identifier().to_string());
+ }
+ }
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("main")
+ }
+
+ async fn branches(&mut self) -> Result<&BTreeMap<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/repository/vcs/hg_driver.rs b/crates/mozart-core/src/repository/vcs/hg_driver.rs
new file mode 100644
index 0000000..525b64f
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/hg_driver.rs
@@ -0,0 +1,203 @@
+use crate::repository::vcs::{DistReference, DriverConfig, SourceReference, VcsDriverInterface};
+use crate::vcs::process::ProcessExecutor;
+use crate::vcs::util::git::GitUtil;
+use crate::vcs::util::hg::HgUtil;
+use anyhow::Result;
+use indexmap::IndexMap;
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+
+/// Mercurial VCS driver.
+///
+/// Corresponds to Composer's `Repository\Vcs\HgDriver`.
+pub struct HgDriver {
+ url: String,
+ repo_dir: Option<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 VcsDriverInterface for HgDriver {
+ async fn initialize(&mut self) -> Result<()> {
+ let cache_dir = &self.config.cache_vcs_dir;
+ std::fs::create_dir_all(cache_dir)?;
+ let repo_dir = cache_dir.join(GitUtil::sanitize_url(&self.url));
+
+ if repo_dir.join(".hg").is_dir() {
+ // Update existing clone
+ self.hg_util.execute(&["pull"], Some(&repo_dir))?;
+ } else {
+ // Clone without checkout
+ let dir_str = repo_dir.to_string_lossy().to_string();
+ self.hg_util
+ .execute(&["clone", "--noupdate", &self.url, &dir_str], None)?;
+ }
+
+ self.repo_dir = Some(repo_dir.clone());
+
+ // Get default branch
+ let output = self.hg_util.execute(
+ &["log", "-r", "default", "--template", "{node|short}"],
+ Some(&repo_dir),
+ );
+ self.root_identifier = match output {
+ Ok(o) if !o.stdout.trim().is_empty() => Some("default".to_string()),
+ _ => Some("tip".to_string()),
+ };
+
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("default")
+ }
+
+ async fn branches(&mut self) -> Result<&BTreeMap<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/repository/vcs/perforce_driver.rs b/crates/mozart-core/src/repository/vcs/perforce_driver.rs
new file mode 100644
index 0000000..0f9a237
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/perforce_driver.rs
@@ -0,0 +1 @@
+pub struct PerforceDriver;
diff --git a/crates/mozart-core/src/repository/vcs/svn_driver.rs b/crates/mozart-core/src/repository/vcs/svn_driver.rs
new file mode 100644
index 0000000..cfd6703
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/svn_driver.rs
@@ -0,0 +1,214 @@
+use crate::repository::vcs::{DistReference, DriverConfig, SourceReference, VcsDriverInterface};
+use crate::vcs::process::ProcessExecutor;
+use crate::vcs::util::svn::SvnUtil;
+use anyhow::Result;
+use indexmap::IndexMap;
+use regex::Regex;
+use std::collections::BTreeMap;
+
+/// SVN VCS driver.
+///
+/// Corresponds to Composer's `Repository\Vcs\SvnDriver`.
+pub struct SvnDriver {
+ url: String,
+ base_url: String,
+ trunk_path: String,
+ branches_path: String,
+ tags_path: String,
+ root_identifier: Option<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 VcsDriverInterface for SvnDriver {
+ async fn initialize(&mut self) -> Result<()> {
+ let info = self.svn_info(&self.url)?;
+ if let Some(url) = info["url"].as_str() {
+ self.base_url = url.to_string();
+ }
+ self.root_identifier = info["revision"].as_str().map(|s| s.to_string());
+ Ok(())
+ }
+
+ fn root_identifier(&self) -> &str {
+ self.root_identifier.as_deref().unwrap_or("HEAD")
+ }
+
+ async fn branches(&mut self) -> Result<&BTreeMap<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/repository/vcs/vcs_driver.rs b/crates/mozart-core/src/repository/vcs/vcs_driver.rs
new file mode 100644
index 0000000..abecc0e
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/vcs_driver.rs
@@ -0,0 +1 @@
+pub struct VcsDriver;
diff --git a/crates/mozart-core/src/repository/vcs/vcs_driver_interface.rs b/crates/mozart-core/src/repository/vcs/vcs_driver_interface.rs
new file mode 100644
index 0000000..49cbd98
--- /dev/null
+++ b/crates/mozart-core/src/repository/vcs/vcs_driver_interface.rs
@@ -0,0 +1,43 @@
+use crate::repository::vcs::{DistReference, SourceReference};
+
+/// The VCS driver interface.
+///
+/// Corresponds to Composer's `VcsDriverInterface`.
+#[allow(async_fn_in_trait)]
+pub trait VcsDriverInterface {
+ /// Initialize the driver (e.g., clone mirror, fetch API metadata).
+ async fn initialize(&mut self) -> anyhow::Result<()>;
+
+ /// The root identifier (default branch/trunk).
+ fn root_identifier(&self) -> &str;
+
+ /// All branches as `name -> commit_hash`.
+ async fn branches(&mut self) -> anyhow::Result<&std::collections::BTreeMap<String, String>>;
+
+ /// All tags as `name -> commit_hash`.
+ async fn tags(&mut self) -> anyhow::Result<&std::collections::BTreeMap<String, String>>;
+
+ /// Get composer.json content parsed as JSON for a given identifier.
+ async fn composer_information(
+ &mut self,
+ identifier: &str,
+ ) -> anyhow::Result<Option<serde_json::Value>>;
+
+ /// Get raw file content at a given path and identifier.
+ async fn file_content(&self, file: &str, identifier: &str) -> anyhow::Result<Option<String>>;
+
+ /// Get the change date for a given identifier (ISO 8601).
+ async fn change_date(&self, identifier: &str) -> anyhow::Result<Option<String>>;
+
+ /// Get the dist reference for a given identifier.
+ async fn dist(&self, identifier: &str) -> anyhow::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) -> anyhow::Result<()>;
+}
diff --git a/crates/mozart-core/src/repository/vcs_bridge.rs b/crates/mozart-core/src/repository/vcs_bridge.rs
index 37d066b..18a8420 100644
--- a/crates/mozart-core/src/repository/vcs_bridge.rs
+++ b/crates/mozart-core/src/repository/vcs_bridge.rs
@@ -3,11 +3,11 @@
//! Scans VCS repositories defined in composer.json and converts
//! discovered package versions into pool inputs for the SAT resolver.
-use super::packagist::PackagistVersion;
-use super::resolver::{parse_normalized, version_stability};
use crate::dependency_resolver::{PoolPackageInput, make_pool_links};
use crate::package::{RawRepository, Stability};
-use crate::vcs::driver::DriverConfig;
+use crate::repository::packagist::PackagistVersion;
+use crate::repository::resolver::{parse_normalized, version_stability};
+use crate::repository::vcs::DriverConfig;
use crate::vcs::repository::{VcsPackageVersion, VcsRepository};
use indexmap::IndexMap;
use std::collections::BTreeMap;