From 2fee33109dbc81dadeb152f38bea3050c4a0bfa2 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 8 May 2026 01:54:51 +0900 Subject: refactor(archiver): extract ArchiveManager from archive command Move source acquisition (root or remote dist download/extract), composer.json archive metadata reading, filename generation, self- exclusion, filter aggregation, and archive creation from the archive command into a new ArchiveManager in mozart-archiver, mirroring Composer's ArchiveCommand <-> ArchiveManager split. The command becomes a thin wrapper that selects the package and delegates archiving. Adds a one-way mozart-archiver -> mozart-registry dep since ArchiveManager::archive() handles dist downloading internally (the analog of Composer's injected DownloadManager). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/mozart/src/commands/archive.rs | 367 +++++++++++----------------------- 1 file changed, 116 insertions(+), 251 deletions(-) (limited to 'crates/mozart/src/commands/archive.rs') diff --git a/crates/mozart/src/commands/archive.rs b/crates/mozart/src/commands/archive.rs index 4655edc..82af052 100644 --- a/crates/mozart/src/commands/archive.rs +++ b/crates/mozart/src/commands/archive.rs @@ -1,9 +1,10 @@ use clap::Args; +use mozart_archiver::{ArchiveManager, ArchivePackage}; use mozart_core::composer::Composer; use mozart_core::console_writeln; use mozart_core::factory::create_config; use std::borrow::Cow; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[derive(Args)] pub struct ArchiveArgs { @@ -30,72 +31,11 @@ pub struct ArchiveArgs { pub ignore_filters: bool, } -/// Read `archive.name` and `archive.exclude` from a composer.json file. -fn read_archive_config( - composer_json_path: &std::path::Path, -) -> anyhow::Result<(Option, Vec)> { - let content = std::fs::read_to_string(composer_json_path)?; - let value: serde_json::Value = serde_json::from_str(&content)?; - - let name = value - .get("archive") - .and_then(|a| a.get("name")) - .and_then(|n| n.as_str()) - .map(|s| s.to_string()); - - let excludes = value - .get("archive") - .and_then(|a| a.get("exclude")) - .and_then(|e| e.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .map(|s| s.to_string()) - .collect() - }) - .unwrap_or_default(); - - Ok((name, excludes)) -} - -struct PackageMeta { - source_dir: PathBuf, - package_name: String, - archive_name: Option, - archive_excludes: Vec, - version: Option, - dist_reference: Option, - dist_type: Option, - source_reference: Option, - /// Holds an optional temp directory that must outlive `source_dir`. - _temp_dir: Option, -} - -impl Drop for PackageMeta { - fn drop(&mut self) { - // Clean up temporary directory used for remote packages - if let Some(ref dir) = self._temp_dir { - let _ = std::fs::remove_dir_all(dir); - } - } -} - pub async fn execute( args: &ArchiveArgs, cli: &super::Cli, - console: &mozart_core::console::Console, + io: &mozart_core::console::Console, ) -> anyhow::Result<()> { - use mozart_archiver::{ - ArchiveFormat, collect_archivable_files, create_archive, generate_archive_filename, - parse_composer_excludes, parse_gitattributes, parse_gitignore_pattern, - self_exclusion_patterns, - }; - - let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); - let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); - let files_cache = mozart_registry::cache::Cache::files(&cache_config); - - // 1. Determine working directory let working_dir = cli.working_dir()?; let composer = Composer::try_load(&working_dir)?; @@ -105,254 +45,179 @@ pub async fn execute( Cow::Owned(create_config()?) }; - let format_str = args.format.as_deref().unwrap_or(&config.archive_format); - let format = ArchiveFormat::parse(format_str).ok_or_else(|| { - anyhow::anyhow!( - "Unsupported archive format \"{}\". Supported formats: tar, tar.gz, tar.bz2, zip", - format_str - ) - })?; + let format = args.format.as_deref().unwrap_or(&config.archive_format); + let dir = args.dir.as_deref().unwrap_or(&config.archive_dir); + + archive( + io, + args.package.as_deref(), + args.version.as_deref(), + format, + dir, + args.file.as_deref(), + args.ignore_filters, + &working_dir, + cli.no_cache, + ) + .await +} - let output_dir_str = args.dir.as_deref().unwrap_or(&config.archive_dir); - let output_dir = if std::path::Path::new(output_dir_str).is_absolute() { - PathBuf::from(output_dir_str) - } else { - working_dir.join(output_dir_str) - }; - std::fs::create_dir_all(&output_dir)?; +#[allow(clippy::too_many_arguments)] +async fn archive( + io: &mozart_core::console::Console, + package_name: Option<&str>, + version: Option<&str>, + format: &str, + dest: &str, + file_name: Option<&str>, + ignore_filters: bool, + working_dir: &Path, + no_cache: bool, +) -> anyhow::Result<()> { + let cache_config = mozart_registry::cache::build_cache_config(no_cache); + let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + let files_cache = mozart_registry::cache::Cache::files(&cache_config); - // 5. Determine source directory and package metadata - let meta: PackageMeta = if let Some(ref pkg_name) = args.package { - // Remote package mode - console.info("Searching for the specified package."); - resolve_remote_package( - pkg_name, - args.version.as_deref(), - &repo_cache, - &files_cache, - console, - ) - .await? + let archive_manager = ArchiveManager::new(); + + let package = if let Some(package_name) = package_name { + select_package(io, package_name, version, &repo_cache).await? } else { - let composer_json_path = working_dir.join("composer.json"); - // Root package mode - if !composer_json_path.exists() { - anyhow::bail!("No composer.json found in {}", working_dir.display()); - } - let root = mozart_core::package::read_from_file(&composer_json_path)?; - let (archive_name, archive_excludes) = read_archive_config(&composer_json_path)?; - let version = root - .extra_fields - .get("version") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - PackageMeta { - source_dir: working_dir.clone(), - package_name: root.name.clone(), - archive_name, - archive_excludes, - version, - dist_reference: None, - dist_type: None, - source_reference: None, - _temp_dir: None, - } + load_root_package(working_dir)? }; - // 6. Generate output filename - let filename_base = if let Some(ref f) = args.file { - f.clone() + let dest_dir = if Path::new(dest).is_absolute() { + PathBuf::from(dest) } else { - generate_archive_filename( - &meta.package_name, - meta.archive_name.as_deref(), - meta.version.as_deref(), - meta.dist_reference.as_deref(), - meta.dist_type.as_deref(), - meta.source_reference.as_deref(), - ) + working_dir.join(dest) }; - // 7. Build exclude patterns - // Self-exclusion: prevent the archive from including itself - let has_extra_parts = args.file.is_none() - && (meta.version.is_some() - || meta.dist_reference.is_some() - || meta.source_reference.is_some()); - let self_exclusion_strs = self_exclusion_patterns(&filename_base, has_extra_parts); - - let mut all_patterns = Vec::new(); + io.info(&format!("Creating the archive into \"{}\".", dest)); + let package_path = archive_manager + .archive( + &package, + format, + &dest_dir, + file_name, + ignore_filters, + &files_cache, + ) + .await?; - // Self-exclusion always applies - for rule in &self_exclusion_strs { - if let Some(p) = parse_gitignore_pattern(rule) { - all_patterns.push(p); - } - } + let absolute = package_path.display().to_string(); + let short_path = package_path + .strip_prefix(working_dir) + .ok() + .map(|rel| rel.display().to_string()) + .filter(|rel| rel.len() < absolute.len()) + .unwrap_or(absolute); + console_writeln!(io, &format!("Created: {}", short_path)); - if !args.ignore_filters { - // Parse .gitattributes export-ignore rules - let git_patterns = parse_gitattributes(&meta.source_dir); - all_patterns.extend(git_patterns); + Ok(()) +} - // Parse composer.json archive.exclude rules - let composer_patterns = parse_composer_excludes(&meta.archive_excludes); - all_patterns.extend(composer_patterns); +fn load_root_package(working_dir: &Path) -> anyhow::Result { + let composer_json_path = working_dir.join("composer.json"); + if !composer_json_path.exists() { + anyhow::bail!("No composer.json found in {}", working_dir.display()); } - - // 8. Collect files - let files = collect_archivable_files(&meta.source_dir, &all_patterns)?; - - // 9. Create archive - let target_path = output_dir.join(format!("{}.{}", filename_base, format.extension())); - console.info(&format!( - "Creating the archive into \"{}\".", - output_dir.display() - )); - create_archive(&meta.source_dir, &files, &target_path, &format)?; - - // Print relative path if possible - let display_path = if let Ok(rel) = target_path.strip_prefix(&working_dir) { - rel.display().to_string() - } else { - target_path.display().to_string() - }; - console_writeln!(console, &format!("Created: {}", display_path),); - - Ok(()) + let root = mozart_core::package::read_from_file(&composer_json_path)?; + let version = root + .extra_fields + .get("version") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Ok(ArchivePackage::Root { + name: root.name.clone(), + version, + source_dir: working_dir.to_path_buf(), + }) } -async fn resolve_remote_package( +async fn select_package( + io: &mozart_core::console::Console, package_name: &str, - version_constraint: Option<&str>, + version: Option<&str>, repo_cache: &mozart_registry::cache::Cache, - files_cache: &mozart_registry::cache::Cache, - console: &mozart_core::console::Console, -) -> anyhow::Result { +) -> anyhow::Result { use mozart_core::package::Stability; use mozart_registry::version::find_best_candidate; - // Parse @stability suffix from version constraint (e.g. "^1.0@beta" → "^1.0", Stability::Beta) - let (constraint_stripped, stability) = if let Some(raw) = version_constraint { + io.info("Searching for the specified package."); + + // Strip @stability suffix from the version constraint (e.g. "^1.0@beta" → "^1.0", Stability::Beta) + let (version, min_stability) = if let Some(raw) = version { if let Some(at_pos) = raw.find('@') { - let ver_part = raw[..at_pos].trim(); + let ver_part = raw[..at_pos].trim().to_string(); let stab_part = raw[at_pos + 1..].trim(); - let stab = Stability::parse(stab_part); - (Some(ver_part.to_string()), stab) + (Some(ver_part), Stability::parse(stab_part)) } else { (Some(raw.to_string()), Stability::Stable) } } else { (None, Stability::Stable) }; - let version_constraint = constraint_stripped.as_deref(); + let version = version.as_deref(); - // Fetch versions from Packagist - let versions = + let packages = mozart_registry::packagist::fetch_package_versions(package_name, repo_cache).await?; - if versions.is_empty() { + if packages.is_empty() { anyhow::bail!("No versions found for package \"{}\"", package_name); } - // Apply version constraint filtering if given - let candidate = if let Some(constraint) = version_constraint { - let matches: Vec<_> = versions + let package = if let Some(version) = version { + let matches: Vec<_> = packages .iter() - .filter(|v| v.version == constraint || v.version_normalized.starts_with(constraint)) + .filter(|v| v.version == version || v.version_normalized.starts_with(version)) .collect(); if matches.is_empty() { anyhow::bail!( "Could not find version \"{}\" for package \"{}\"", - constraint, + version, package_name ); } - let best = matches[0]; + let package = matches[0]; if matches.len() > 1 { - console.info(&format!( + io.info(&format!( "Found multiple matches, selected {} {}.", - package_name, best.version + package_name, package.version )); } else { - console.info(&format!( + io.info(&format!( "Found an exact match {} {}.", - package_name, best.version + package_name, package.version )); } - best + package } else { - let best = find_best_candidate(&versions, stability) - .or_else(|| find_best_candidate(&versions, Stability::Dev)) + let package = find_best_candidate(&packages, min_stability) + .or_else(|| find_best_candidate(&packages, Stability::Dev)) .ok_or_else(|| { anyhow::anyhow!("No suitable version found for package \"{}\"", package_name) })?; - console.info(&format!( + io.info(&format!( "Found an exact match {} {}.", - package_name, best.version + package_name, package.version )); - best + package }; - let dist = candidate.dist.as_ref().ok_or_else(|| { + let dist = package.dist.as_ref().ok_or_else(|| { anyhow::anyhow!( "Package \"{}\" version \"{}\" has no dist available", package_name, - candidate.version + package.version ) })?; - // Create a temp directory using std (not tempfile crate, which is dev-only) - let temp_base = std::env::temp_dir(); - let unique = format!( - "mozart-archive-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - ); - let temp_dir = temp_base.join(&unique); - std::fs::create_dir_all(&temp_dir)?; - - let bytes = mozart_registry::downloader::download_dist( - &dist.url, - dist.shasum.as_deref(), - None, - files_cache, - ) - .await?; - - match dist.dist_type.as_str() { - "zip" => mozart_registry::downloader::extract_zip(&bytes, &temp_dir)?, - "tar" | "tar.gz" | "tgz" => mozart_registry::downloader::extract_tar_gz(&bytes, &temp_dir)?, - other => { - let _ = std::fs::remove_dir_all(&temp_dir); - anyhow::bail!("Unsupported dist type: {}", other); - } - } - - // Try to read composer.json from the extracted source for archive.name / archive.exclude - let extracted_composer = temp_dir.join("composer.json"); - let (archive_name, archive_excludes) = if extracted_composer.exists() { - read_archive_config(&extracted_composer).unwrap_or((None, vec![])) - } else { - (None, vec![]) - }; - - let version: Option = Some(candidate.version.clone()); - let dist_reference: Option = dist.reference.clone(); - let dist_type: Option = Some(dist.dist_type.clone()); - let source_reference: Option = - candidate.source.as_ref().and_then(|s| s.reference.clone()); - - Ok(PackageMeta { - source_dir: temp_dir.clone(), - package_name: package_name.to_string(), - archive_name, - archive_excludes, - version, - dist_reference, - dist_type, - source_reference, - _temp_dir: Some(temp_dir), + Ok(ArchivePackage::Remote { + name: package_name.to_string(), + version: package.version.clone(), + dist_url: dist.url.clone(), + dist_type: dist.dist_type.clone(), + dist_shasum: dist.shasum.clone(), + dist_reference: dist.reference.clone(), + source_reference: package.source.as_ref().and_then(|s| s.reference.clone()), }) } -- cgit v1.3.1