aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src/vcs/repository.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
committernsfisis <nsfisis@gmail.com>2026-05-10 00:32:08 +0900
commit8cc1ba8a02c0318b65658f1634de378c780392b9 (patch)
treefdd5cb61e488018891a486b25991b87c84220bb8 /crates/mozart-core/src/vcs/repository.rs
parent72b2e877c01e67ba7edd37e34ac2eadb7a1c62c4 (diff)
downloadphp-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.gz
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.zst
php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.zip
refactor(workspace): consolidate crates into mozart-core
Merged mozart-archiver, mozart-autoload, mozart-registry, mozart-sat-resolver, and mozart-vcs into mozart-core to align the source layout with Composer's structure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-core/src/vcs/repository.rs')
-rw-r--r--crates/mozart-core/src/vcs/repository.rs205
1 files changed, 205 insertions, 0 deletions
diff --git a/crates/mozart-core/src/vcs/repository.rs b/crates/mozart-core/src/vcs/repository.rs
new file mode 100644
index 0000000..55f98f9
--- /dev/null
+++ b/crates/mozart-core/src/vcs/repository.rs
@@ -0,0 +1,205 @@
+use super::driver::{
+ DistReference, DriverConfig, DriverType, SourceReference, create_driver, detect_driver,
+};
+use anyhow::{Result, bail};
+
+/// A single package version discovered from a VCS repository.
+#[derive(Debug, Clone)]
+pub struct VcsPackageVersion {
+ /// Package name (from composer.json).
+ pub name: String,
+ /// Version string (e.g., "1.2.3" for tags, "dev-main" for branches).
+ pub version: String,
+ /// Normalized version for comparison.
+ pub version_normalized: String,
+ /// Full composer.json data as JSON.
+ pub composer_json: serde_json::Value,
+ /// Source reference (VCS checkout info).
+ pub source: SourceReference,
+ /// Dist reference (archive download, if available).
+ pub dist: Option<DistReference>,
+ /// Whether this is the default branch version.
+ pub is_default_branch: bool,
+ /// Release date (ISO 8601).
+ pub time: Option<String>,
+}
+
+/// Repository that scans a VCS URL for package versions.
+///
+/// Corresponds to Composer's `Repository\VcsRepository`.
+pub struct VcsRepository {
+ url: String,
+ driver_type: Option<DriverType>,
+ config: DriverConfig,
+}
+
+impl VcsRepository {
+ pub fn new(url: String, repo_type: Option<&str>, config: DriverConfig) -> Self {
+ let driver_type = detect_driver(&url, repo_type, &config);
+ Self {
+ url,
+ driver_type,
+ config,
+ }
+ }
+
+ /// Scan the VCS repository for all package versions.
+ ///
+ /// 1. Detects the driver type and initializes it
+ /// 2. Reads composer.json from the root to get the package name
+ /// 3. Scans tags → version releases
+ /// 4. Scans branches → dev versions
+ pub async fn scan(&self) -> Result<Vec<VcsPackageVersion>> {
+ let driver_type = self
+ .driver_type
+ .ok_or_else(|| anyhow::anyhow!("No suitable VCS driver found for URL: {}", self.url))?;
+
+ let mut driver = create_driver(&self.url, driver_type, self.config.clone());
+ driver.initialize().await?;
+
+ // Get package name from root composer.json
+ let root_id = driver.root_identifier().to_string();
+ let root_info = driver.composer_information(&root_id).await?;
+ let package_name = match &root_info {
+ Some(info) => info["name"]
+ .as_str()
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "composer.json at root of {} does not contain a 'name' field",
+ self.url,
+ )
+ })?
+ .to_string(),
+ None => bail!(
+ "No composer.json found at root of {} (ref: {})",
+ self.url,
+ root_id,
+ ),
+ };
+
+ let mut versions = Vec::new();
+
+ // Scan tags
+ let tags = driver.tags().await?.clone();
+ for (tag_name, tag_hash) in &tags {
+ if let Some(version) = self.tag_to_version(tag_name) {
+ match driver.composer_information(tag_hash).await {
+ Ok(Some(info)) => {
+ let time = driver.change_date(tag_hash).await.unwrap_or(None);
+ let source = driver.source(tag_hash);
+ let dist = driver.dist(tag_hash).await.unwrap_or(None);
+
+ // Ensure name matches root package
+ if info["name"].as_str() != Some(&package_name) {
+ continue;
+ }
+
+ let normalized = self.normalize_version(&version);
+
+ versions.push(VcsPackageVersion {
+ name: package_name.clone(),
+ version: version.clone(),
+ version_normalized: normalized,
+ composer_json: info,
+ source,
+ dist,
+ is_default_branch: false,
+ time,
+ });
+ }
+ Ok(None) | Err(_) => continue,
+ }
+ }
+ }
+
+ // Scan branches
+ let branches = driver.branches().await?.clone();
+ let default_branch = driver.root_identifier().to_string();
+ for (branch_name, branch_hash) in &branches {
+ match driver.composer_information(branch_hash).await {
+ Ok(Some(info)) => {
+ if info["name"].as_str() != Some(&package_name) {
+ continue;
+ }
+
+ let time = driver.change_date(branch_hash).await.unwrap_or(None);
+ let source = driver.source(branch_hash);
+ let dist = driver.dist(branch_hash).await.unwrap_or(None);
+ let is_default = branch_name == &default_branch;
+
+ let version = self.branch_to_version(branch_name);
+ let normalized = self.normalize_version(&version);
+
+ // Check for branch-alias
+ let aliased_version = info
+ .get("extra")
+ .and_then(|e| e.get("branch-alias"))
+ .and_then(|ba| ba.get(format!("dev-{branch_name}")))
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ versions.push(VcsPackageVersion {
+ name: package_name.clone(),
+ version: aliased_version.unwrap_or(version),
+ version_normalized: normalized,
+ composer_json: info,
+ source,
+ dist,
+ is_default_branch: is_default,
+ time,
+ });
+ }
+ Ok(None) | Err(_) => continue,
+ }
+ }
+
+ driver.cleanup().await?;
+ Ok(versions)
+ }
+
+ /// Convert a tag name to a version string.
+ /// Returns `None` if the tag doesn't look like a version.
+ fn tag_to_version(&self, tag: &str) -> Option<String> {
+ // Strip common prefixes
+ let version = tag
+ .strip_prefix('v')
+ .or_else(|| tag.strip_prefix("V"))
+ .or_else(|| tag.strip_prefix("release-"))
+ .or_else(|| tag.strip_prefix("release/"))
+ .unwrap_or(tag);
+
+ // Basic semver-ish check
+ if version.is_empty() {
+ return None;
+ }
+ if version.chars().next()?.is_ascii_digit() {
+ Some(version.to_string())
+ } else {
+ None
+ }
+ }
+
+ /// Convert a branch name to a dev version string.
+ fn branch_to_version(&self, branch: &str) -> String {
+ // Numeric branches like "1.x", "2.0" become "1.x-dev", "2.0.x-dev"
+ if branch.chars().next().is_some_and(|c| c.is_ascii_digit()) {
+ let version = if branch.ends_with(".x") || branch.ends_with(".*") {
+ branch.to_string()
+ } else {
+ format!("{branch}.x")
+ };
+ format!("{version}-dev")
+ } else {
+ format!("dev-{branch}")
+ }
+ }
+
+ /// Normalize a version string.
+ fn normalize_version(&self, version: &str) -> String {
+ // Use mozart-semver for proper normalization if available,
+ // otherwise do a simple normalization
+ mozart_semver::Version::parse(version)
+ .map(|v| v.to_string())
+ .unwrap_or_else(|_| version.to_string())
+ }
+}