aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 11:04:06 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 11:04:06 +0900
commitf030dc63ea84a13f1e494a1541b6120c0969f383 (patch)
tree578dc7eec648c0b9ac50bcb234430fa913c0e23c /crates/mozart
parenta03ad0152ec28cdd1cc05f96a9823807dbb2b818 (diff)
downloadphp-mozart-f030dc63ea84a13f1e494a1541b6120c0969f383.tar.gz
php-mozart-f030dc63ea84a13f1e494a1541b6120c0969f383.tar.zst
php-mozart-f030dc63ea84a13f1e494a1541b6120c0969f383.zip
feat(install): implement install command with lock-based package installation
Replaces the todo!() stub with a full implementation that reads composer.lock, computes install/update/skip/remove operations by comparing against vendor/composer/installed.json, downloads packages via the downloader module, and writes the updated installed registry. Handles edge cases: missing lock file, stale lock file, no dist info, empty packages, --dry-run, --no-dev, deprecated flags, and vendor directory cleanup after removals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart')
-rw-r--r--crates/mozart/src/commands/install.rs683
-rw-r--r--crates/mozart/src/constraint.rs5
-rw-r--r--crates/mozart/src/downloader.rs4
3 files changed, 686 insertions, 6 deletions
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs
index fbff8a4..ddf7be8 100644
--- a/crates/mozart/src/commands/install.rs
+++ b/crates/mozart/src/commands/install.rs
@@ -1,4 +1,10 @@
+use crate::console;
+use crate::downloader;
+use crate::installed;
+use crate::lockfile;
use clap::Args;
+use std::collections::{BTreeMap, HashSet};
+use std::path::{Path, PathBuf};
#[derive(Args)]
pub struct InstallArgs {
@@ -86,6 +92,679 @@ pub struct InstallArgs {
pub ignore_platform_reqs: bool,
}
-pub fn execute(_args: &InstallArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+/// The action to take for a package during install.
+#[derive(Debug, PartialEq, Eq)]
+pub enum Action {
+ Install,
+ Update,
+ Skip,
+}
+
+/// An operation to perform during install.
+pub struct InstallOp<'a> {
+ pub package: &'a lockfile::LockedPackage,
+ pub action: Action,
+}
+
+/// Resolve the working directory from the CLI option, falling back to cwd.
+fn resolve_working_dir(cli: &super::Cli) -> PathBuf {
+ match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir().expect("Failed to determine current directory"),
+ }
+}
+
+/// Compute install operations by comparing locked packages against installed packages.
+///
+/// Returns a tuple of (ops, removals) where:
+/// - ops: list of (package, action) for each locked package
+/// - removals: list of package names that are installed but not locked
+pub fn compute_operations<'a>(
+ locked: &[&'a lockfile::LockedPackage],
+ installed: &installed::InstalledPackages,
+) -> (Vec<(&'a lockfile::LockedPackage, Action)>, Vec<String>) {
+ let mut ops: Vec<(&'a lockfile::LockedPackage, Action)> = Vec::new();
+
+ for pkg in locked {
+ if installed.is_installed(&pkg.name, &pkg.version) {
+ ops.push((pkg, Action::Skip));
+ } else if installed
+ .packages
+ .iter()
+ .any(|p| p.name.eq_ignore_ascii_case(&pkg.name))
+ {
+ ops.push((pkg, Action::Update));
+ } else {
+ ops.push((pkg, Action::Install));
+ }
+ }
+
+ // Compute removals: packages in installed but not in locked
+ let locked_names: HashSet<String> = locked.iter().map(|p| p.name.to_lowercase()).collect();
+
+ let removals: Vec<String> = installed
+ .packages
+ .iter()
+ .filter(|p| !locked_names.contains(&p.name.to_lowercase()))
+ .map(|p| p.name.clone())
+ .collect();
+
+ (ops, removals)
+}
+
+/// Convert a LockedPackage to an InstalledPackageEntry.
+pub fn locked_to_installed_entry(
+ pkg: &lockfile::LockedPackage,
+ _vendor_dir: &Path,
+) -> installed::InstalledPackageEntry {
+ // Composer uses a path relative to vendor/composer/installed.json
+ let install_path = format!("../{}", pkg.name);
+
+ installed::InstalledPackageEntry {
+ name: pkg.name.clone(),
+ version: pkg.version.clone(),
+ version_normalized: pkg.version_normalized.clone(),
+ source: pkg
+ .source
+ .as_ref()
+ .map(|s| serde_json::to_value(s).unwrap_or_default()),
+ dist: pkg
+ .dist
+ .as_ref()
+ .map(|d| serde_json::to_value(d).unwrap_or_default()),
+ package_type: pkg.package_type.clone(),
+ install_path: Some(install_path),
+ autoload: pkg.autoload.clone(),
+ aliases: vec![],
+ extra_fields: BTreeMap::new(),
+ }
+}
+
+/// Clean up empty vendor namespace directories after removals.
+fn cleanup_empty_vendor_dirs(vendor_dir: &Path) -> anyhow::Result<()> {
+ if let Ok(entries) = std::fs::read_dir(vendor_dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ let name = entry.file_name().to_string_lossy().to_string();
+ // Skip "composer" dir and "bin" dir
+ if name == "composer" || name == "bin" {
+ continue;
+ }
+ // If the namespace dir is empty, remove it
+ if std::fs::read_dir(&path)?.next().is_none() {
+ std::fs::remove_dir(&path)?;
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ // Step 1: Resolve the working directory
+ let working_dir = resolve_working_dir(cli);
+
+ // Step 2: Validate arguments
+ if !args.packages.is_empty() {
+ let pkgs = args.packages.join(" ");
+ eprintln!(
+ "{}",
+ console::error(&format!(
+ "Invalid argument {pkgs}. Use \"mozart require {pkgs}\" instead to add packages to your composer.json."
+ ))
+ );
+ std::process::exit(1);
+ }
+
+ if args.no_install {
+ eprintln!(
+ "{}",
+ console::error(
+ "Invalid option \"--no-install\". Use \"mozart update --no-install\" instead if you are trying to update the composer.lock file."
+ )
+ );
+ std::process::exit(1);
+ }
+
+ if args.dev {
+ eprintln!(
+ "{}",
+ console::warning(
+ "The --dev option is deprecated. Dev packages are installed by default."
+ )
+ );
+ }
+
+ if args.no_suggest {
+ eprintln!(
+ "{}",
+ console::warning("The --no-suggest option is deprecated and has no effect.")
+ );
+ }
+
+ // Step 3: Read composer.lock
+ let lock_path = working_dir.join("composer.lock");
+ if !lock_path.exists() {
+ eprintln!(
+ "{}",
+ console::warning(
+ "No composer.lock file present. Run \"mozart update\" to generate one."
+ )
+ );
+ std::process::exit(1);
+ }
+ let lock = lockfile::LockFile::read_from_file(&lock_path)?;
+
+ // Step 4: Freshness check
+ let composer_json_path = working_dir.join("composer.json");
+ if composer_json_path.exists() {
+ let content = std::fs::read_to_string(&composer_json_path)?;
+ if !lock.is_fresh(&content) {
+ eprintln!(
+ "{}",
+ console::warning(
+ "Warning: The lock file is not up to date with the latest changes in composer.json. You may be getting outdated dependencies. It is recommended that you run `mozart update`."
+ )
+ );
+ }
+ }
+
+ // Step 5: Determine which packages to install
+ let dev_mode = !args.no_dev;
+
+ let mut packages_to_install: Vec<&lockfile::LockedPackage> = lock.packages.iter().collect();
+
+ if dev_mode && let Some(ref dev_pkgs) = lock.packages_dev {
+ packages_to_install.extend(dev_pkgs.iter());
+ }
+
+ // Print install mode header
+ if dev_mode {
+ eprintln!("Installing dependencies from lock file (including require-dev)");
+ } else {
+ eprintln!("Installing dependencies from lock file");
+ }
+ eprintln!("Verifying lock file contents can be installed on current platform.");
+
+ // Step 6: Determine the vendor directory
+ let vendor_dir = working_dir.join("vendor");
+
+ // Step 7: Read currently installed packages
+ let installed = installed::InstalledPackages::read(&vendor_dir)?;
+
+ // Step 8: Compute install operations
+ let (ops, removals) = compute_operations(&packages_to_install, &installed);
+
+ // Step 9: Print operation summary
+ let installs: Vec<_> = ops
+ .iter()
+ .filter(|(_, a)| matches!(a, Action::Install))
+ .collect();
+ let updates: Vec<_> = ops
+ .iter()
+ .filter(|(_, a)| matches!(a, Action::Update))
+ .collect();
+
+ if installs.is_empty() && updates.is_empty() && removals.is_empty() {
+ eprintln!("Nothing to install, update or remove");
+ } else {
+ eprintln!(
+ "{}",
+ console::info(&format!(
+ "Package operations: {} install{}, {} update{}, {} removal{}",
+ installs.len(),
+ if installs.len() == 1 { "" } else { "s" },
+ updates.len(),
+ if updates.len() == 1 { "" } else { "s" },
+ removals.len(),
+ if removals.len() == 1 { "" } else { "s" },
+ ))
+ );
+ }
+
+ // Step 10: Execute operations (unless --dry-run)
+ if args.dry_run {
+ for (pkg, action) in &ops {
+ match action {
+ Action::Skip => {}
+ Action::Install => {
+ eprintln!(" - Would install {} ({})", pkg.name, pkg.version);
+ }
+ Action::Update => {
+ eprintln!(" - Would update {} ({})", pkg.name, pkg.version);
+ }
+ }
+ }
+ for name in &removals {
+ eprintln!(" - Would remove {name}");
+ }
+ } else {
+ for (pkg, action) in &ops {
+ match action {
+ Action::Skip => continue,
+ Action::Install => {
+ eprintln!(" - Installing {} ({})", pkg.name, pkg.version);
+ }
+ Action::Update => {
+ eprintln!(" - Updating {} ({})", pkg.name, pkg.version);
+ }
+ }
+
+ let dist = pkg.dist.as_ref().ok_or_else(|| {
+ anyhow::anyhow!(
+ "Package {} has no dist information — source installs are not yet supported",
+ pkg.name
+ )
+ })?;
+
+ downloader::install_package(
+ &dist.url,
+ &dist.dist_type,
+ dist.shasum.as_deref(),
+ &vendor_dir,
+ &pkg.name,
+ )?;
+ }
+
+ // Handle removals
+ for name in &removals {
+ eprintln!(" - Removing {name}");
+ let pkg_dir = vendor_dir.join(name);
+ if pkg_dir.exists() {
+ std::fs::remove_dir_all(&pkg_dir)?;
+ }
+ }
+
+ // Step 13: Clean up empty vendor namespace directories
+ if !removals.is_empty() {
+ cleanup_empty_vendor_dirs(&vendor_dir)?;
+ }
+
+ // Step 11: Write updated vendor/composer/installed.json
+ let mut new_installed = installed::InstalledPackages::new();
+ new_installed.dev = dev_mode;
+
+ // Collect dev package names from lock
+ if dev_mode && let Some(ref dev_pkgs) = lock.packages_dev {
+ new_installed.dev_package_names = dev_pkgs.iter().map(|p| p.name.clone()).collect();
+ }
+
+ for pkg in &packages_to_install {
+ new_installed.upsert(locked_to_installed_entry(pkg, &vendor_dir));
+ }
+
+ new_installed.write(&vendor_dir)?;
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeMap;
+ use tempfile::tempdir;
+
+ fn make_locked_package(name: &str, version: &str) -> lockfile::LockedPackage {
+ lockfile::LockedPackage {
+ name: name.to_string(),
+ version: version.to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ require: BTreeMap::new(),
+ require_dev: BTreeMap::new(),
+ suggest: None,
+ package_type: Some("library".to_string()),
+ autoload: None,
+ autoload_dev: None,
+ license: None,
+ description: None,
+ homepage: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None,
+ time: None,
+ extra_fields: BTreeMap::new(),
+ }
+ }
+
+ fn make_installed_entry(name: &str, version: &str) -> installed::InstalledPackageEntry {
+ installed::InstalledPackageEntry {
+ name: name.to_string(),
+ version: version.to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ package_type: None,
+ install_path: None,
+ autoload: None,
+ aliases: vec![],
+ extra_fields: BTreeMap::new(),
+ }
+ }
+
+ fn minimal_lock(packages: Vec<lockfile::LockedPackage>) -> lockfile::LockFile {
+ lockfile::LockFile {
+ readme: lockfile::LockFile::default_readme(),
+ content_hash: "abc123".to_string(),
+ packages,
+ packages_dev: Some(vec![]),
+ aliases: vec![],
+ minimum_stability: "stable".to_string(),
+ stability_flags: serde_json::json!({}),
+ prefer_stable: false,
+ prefer_lowest: false,
+ platform: serde_json::json!({}),
+ platform_dev: serde_json::json!({}),
+ plugin_api_version: Some("2.6.0".to_string()),
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // compute_operations tests
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn test_compute_operations_all_new() {
+ let locked = vec![
+ make_locked_package("psr/log", "3.0.0"),
+ make_locked_package("monolog/monolog", "3.8.0"),
+ ];
+ let locked_refs: Vec<&lockfile::LockedPackage> = locked.iter().collect();
+ let installed = installed::InstalledPackages::new();
+
+ let (ops, removals) = compute_operations(&locked_refs, &installed);
+
+ assert_eq!(ops.len(), 2);
+ assert!(matches!(ops[0].1, Action::Install));
+ assert!(matches!(ops[1].1, Action::Install));
+ assert!(removals.is_empty());
+ }
+
+ #[test]
+ fn test_compute_operations_all_skipped() {
+ let locked = vec![make_locked_package("psr/log", "3.0.0")];
+ let locked_refs: Vec<&lockfile::LockedPackage> = locked.iter().collect();
+ let mut installed = installed::InstalledPackages::new();
+ installed.upsert(make_installed_entry("psr/log", "3.0.0"));
+
+ let (ops, removals) = compute_operations(&locked_refs, &installed);
+
+ assert_eq!(ops.len(), 1);
+ assert!(matches!(ops[0].1, Action::Skip));
+ assert!(removals.is_empty());
+ }
+
+ #[test]
+ fn test_compute_operations_update_needed() {
+ let locked = vec![make_locked_package("psr/log", "3.0.1")];
+ let locked_refs: Vec<&lockfile::LockedPackage> = locked.iter().collect();
+ let mut installed = installed::InstalledPackages::new();
+ installed.upsert(make_installed_entry("psr/log", "3.0.0"));
+
+ let (ops, removals) = compute_operations(&locked_refs, &installed);
+
+ assert_eq!(ops.len(), 1);
+ assert!(matches!(ops[0].1, Action::Update));
+ assert!(removals.is_empty());
+ }
+
+ #[test]
+ fn test_compute_operations_removals() {
+ let locked = vec![make_locked_package("psr/log", "3.0.0")];
+ let locked_refs: Vec<&lockfile::LockedPackage> = locked.iter().collect();
+ let mut installed = installed::InstalledPackages::new();
+ installed.upsert(make_installed_entry("psr/log", "3.0.0"));
+ installed.upsert(make_installed_entry("monolog/monolog", "3.8.0"));
+
+ let (ops, removals) = compute_operations(&locked_refs, &installed);
+
+ assert_eq!(ops.len(), 1);
+ assert!(matches!(ops[0].1, Action::Skip));
+ assert_eq!(removals.len(), 1);
+ assert_eq!(removals[0], "monolog/monolog");
+ }
+
+ #[test]
+ fn test_compute_operations_mixed() {
+ let locked = vec![
+ make_locked_package("psr/log", "3.0.0"),
+ make_locked_package("symfony/console", "7.2.3"),
+ make_locked_package("monolog/monolog", "3.8.1"),
+ ];
+ let locked_refs: Vec<&lockfile::LockedPackage> = locked.iter().collect();
+ let mut installed = installed::InstalledPackages::new();
+ // psr/log already at correct version -> skip
+ installed.upsert(make_installed_entry("psr/log", "3.0.0"));
+ // monolog at wrong version -> update
+ installed.upsert(make_installed_entry("monolog/monolog", "3.8.0"));
+ // old-package not in locked -> removal
+ installed.upsert(make_installed_entry("old/package", "1.0.0"));
+ // symfony/console not installed at all -> install
+
+ let (ops, removals) = compute_operations(&locked_refs, &installed);
+
+ assert_eq!(ops.len(), 3);
+
+ let psr = ops.iter().find(|(p, _)| p.name == "psr/log").unwrap();
+ assert!(matches!(psr.1, Action::Skip));
+
+ let symfony = ops
+ .iter()
+ .find(|(p, _)| p.name == "symfony/console")
+ .unwrap();
+ assert!(matches!(symfony.1, Action::Install));
+
+ let monolog = ops
+ .iter()
+ .find(|(p, _)| p.name == "monolog/monolog")
+ .unwrap();
+ assert!(matches!(monolog.1, Action::Update));
+
+ assert_eq!(removals.len(), 1);
+ assert_eq!(removals[0], "old/package");
+ }
+
+ #[test]
+ fn test_compute_operations_case_insensitive() {
+ let locked = vec![make_locked_package("Monolog/Monolog", "3.8.0")];
+ let locked_refs: Vec<&lockfile::LockedPackage> = locked.iter().collect();
+ let mut installed = installed::InstalledPackages::new();
+ installed.upsert(make_installed_entry("monolog/monolog", "3.8.0"));
+
+ let (ops, removals) = compute_operations(&locked_refs, &installed);
+
+ assert_eq!(ops.len(), 1);
+ assert!(matches!(ops[0].1, Action::Skip));
+ assert!(removals.is_empty());
+ }
+
+ #[test]
+ fn test_compute_operations_empty_lock() {
+ let locked: Vec<lockfile::LockedPackage> = vec![];
+ let locked_refs: Vec<&lockfile::LockedPackage> = locked.iter().collect();
+ let mut installed = installed::InstalledPackages::new();
+ installed.upsert(make_installed_entry("old/package", "1.0.0"));
+
+ let (ops, removals) = compute_operations(&locked_refs, &installed);
+
+ assert!(ops.is_empty());
+ assert_eq!(removals.len(), 1);
+ assert_eq!(removals[0], "old/package");
+ }
+
+ // -----------------------------------------------------------------------
+ // locked_to_installed_entry tests
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn test_locked_to_installed_entry_conversion() {
+ let dir = tempdir().unwrap();
+ let vendor_dir = dir.path().join("vendor");
+
+ let mut pkg = make_locked_package("psr/log", "3.0.2");
+ pkg.version_normalized = Some("3.0.2.0".to_string());
+ pkg.package_type = Some("library".to_string());
+ pkg.autoload = Some(serde_json::json!({"psr-4": {"Psr\\Log\\": "src/"}}));
+
+ let entry = locked_to_installed_entry(&pkg, &vendor_dir);
+
+ assert_eq!(entry.name, "psr/log");
+ assert_eq!(entry.version, "3.0.2");
+ assert_eq!(entry.version_normalized.as_deref(), Some("3.0.2.0"));
+ assert_eq!(entry.package_type.as_deref(), Some("library"));
+ assert_eq!(entry.install_path.as_deref(), Some("../psr/log"));
+ assert!(entry.autoload.is_some());
+ assert!(entry.aliases.is_empty());
+ assert!(entry.extra_fields.is_empty());
+ assert!(entry.source.is_none());
+ assert!(entry.dist.is_none());
+ }
+
+ #[test]
+ fn test_locked_to_installed_entry_with_dist() {
+ let dir = tempdir().unwrap();
+ let vendor_dir = dir.path().join("vendor");
+
+ let mut pkg = make_locked_package("monolog/monolog", "3.8.0");
+ pkg.dist = Some(lockfile::LockedDist {
+ dist_type: "zip".to_string(),
+ url: "https://example.com/monolog.zip".to_string(),
+ reference: Some("abc123".to_string()),
+ shasum: Some("deadbeef".to_string()),
+ });
+
+ let entry = locked_to_installed_entry(&pkg, &vendor_dir);
+
+ assert_eq!(entry.name, "monolog/monolog");
+ assert_eq!(entry.install_path.as_deref(), Some("../monolog/monolog"));
+ assert!(entry.dist.is_some());
+ }
+
+ // -----------------------------------------------------------------------
+ // installed.json generation tests
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn test_installed_json_written_from_lock() {
+ let dir = tempdir().unwrap();
+ let vendor_dir = dir.path().join("vendor");
+
+ // Write a lock file
+ let lock_path = dir.path().join("composer.lock");
+ let lock = minimal_lock(vec![
+ make_locked_package("psr/log", "3.0.2"),
+ make_locked_package("vendor/pkg", "1.2.3"),
+ ]);
+ lock.write_to_file(&lock_path).unwrap();
+
+ // Simulate what execute() does for the installed.json write step
+ let packages_to_install: Vec<&lockfile::LockedPackage> = lock.packages.iter().collect();
+ let mut new_installed = installed::InstalledPackages::new();
+ new_installed.dev = false;
+ for pkg in &packages_to_install {
+ new_installed.upsert(locked_to_installed_entry(pkg, &vendor_dir));
+ }
+ new_installed.write(&vendor_dir).unwrap();
+
+ // Verify installed.json
+ let loaded = installed::InstalledPackages::read(&vendor_dir).unwrap();
+ assert_eq!(loaded.packages.len(), 2);
+ assert!(loaded.is_installed("psr/log", "3.0.2"));
+ assert!(loaded.is_installed("vendor/pkg", "1.2.3"));
+ assert_eq!(
+ loaded
+ .packages
+ .iter()
+ .find(|p| p.name == "psr/log")
+ .unwrap()
+ .install_path
+ .as_deref(),
+ Some("../psr/log")
+ );
+ }
+
+ #[test]
+ fn test_installed_json_dev_package_names() {
+ let dir = tempdir().unwrap();
+ let vendor_dir = dir.path().join("vendor");
+
+ let mut lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.2")]);
+ lock.packages_dev = Some(vec![make_locked_package("phpunit/phpunit", "11.0.0")]);
+
+ // Simulate dev mode installed.json generation
+ let mut packages_to_install: Vec<&lockfile::LockedPackage> = lock.packages.iter().collect();
+ if let Some(ref dev_pkgs) = lock.packages_dev {
+ packages_to_install.extend(dev_pkgs.iter());
+ }
+
+ let mut new_installed = installed::InstalledPackages::new();
+ new_installed.dev = true;
+ if let Some(ref dev_pkgs) = lock.packages_dev {
+ new_installed.dev_package_names = dev_pkgs.iter().map(|p| p.name.clone()).collect();
+ }
+ for pkg in &packages_to_install {
+ new_installed.upsert(locked_to_installed_entry(pkg, &vendor_dir));
+ }
+ new_installed.write(&vendor_dir).unwrap();
+
+ let loaded = installed::InstalledPackages::read(&vendor_dir).unwrap();
+ assert_eq!(loaded.packages.len(), 2);
+ assert!(loaded.dev);
+ assert_eq!(loaded.dev_package_names, vec!["phpunit/phpunit"]);
+ }
+
+ // -----------------------------------------------------------------------
+ // cleanup_empty_vendor_dirs tests
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn test_cleanup_empty_vendor_dirs_removes_empty() {
+ let dir = tempdir().unwrap();
+ let vendor_dir = dir.path().join("vendor");
+ std::fs::create_dir_all(&vendor_dir).unwrap();
+
+ // Create an empty namespace dir
+ let empty_ns = vendor_dir.join("old-vendor");
+ std::fs::create_dir_all(&empty_ns).unwrap();
+
+ // Create a non-empty namespace dir
+ let nonempty_ns = vendor_dir.join("psr");
+ std::fs::create_dir_all(nonempty_ns.join("log")).unwrap();
+
+ // Create the composer dir (should be skipped)
+ std::fs::create_dir_all(vendor_dir.join("composer")).unwrap();
+
+ cleanup_empty_vendor_dirs(&vendor_dir).unwrap();
+
+ assert!(!empty_ns.exists(), "empty namespace dir should be removed");
+ assert!(
+ vendor_dir.join("psr").exists(),
+ "non-empty namespace dir should remain"
+ );
+ assert!(
+ vendor_dir.join("composer").exists(),
+ "composer dir should be preserved"
+ );
+ }
+
+ #[test]
+ fn test_cleanup_empty_vendor_dirs_skips_bin() {
+ let dir = tempdir().unwrap();
+ let vendor_dir = dir.path().join("vendor");
+ std::fs::create_dir_all(&vendor_dir).unwrap();
+
+ let bin_dir = vendor_dir.join("bin");
+ std::fs::create_dir_all(&bin_dir).unwrap();
+
+ cleanup_empty_vendor_dirs(&vendor_dir).unwrap();
+
+ assert!(
+ bin_dir.exists(),
+ "bin dir should be preserved even if empty"
+ );
+ }
}
diff --git a/crates/mozart/src/constraint.rs b/crates/mozart/src/constraint.rs
index ff9b14e..d009028 100644
--- a/crates/mozart/src/constraint.rs
+++ b/crates/mozart/src/constraint.rs
@@ -235,7 +235,10 @@ fn normalize_pre_release(s: &str) -> String {
.to_string();
// Extract the alphabetic prefix (stability name)
- let alpha: String = normalized.chars().take_while(|c| c.is_alphabetic()).collect();
+ let alpha: String = normalized
+ .chars()
+ .take_while(|c| c.is_alphabetic())
+ .collect();
// Extract only digits from the rest (strip separators like dots)
let num: String = normalized
.chars()
diff --git a/crates/mozart/src/downloader.rs b/crates/mozart/src/downloader.rs
index b477ab4..403f4d6 100644
--- a/crates/mozart/src/downloader.rs
+++ b/crates/mozart/src/downloader.rs
@@ -30,9 +30,7 @@ pub fn download_dist(url: &str, expected_shasum: Option<&str>) -> anyhow::Result
let computed = format!("{result:x}");
if computed != shasum {
- anyhow::bail!(
- "SHA-1 checksum mismatch for {url}: expected {shasum}, got {computed}"
- );
+ anyhow::bail!("SHA-1 checksum mismatch for {url}: expected {shasum}, got {computed}");
}
}