aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 18:16:35 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 18:16:35 +0900
commit6d22ea76e12cbf0c6e1b873d3350d1bfad2c5442 (patch)
tree0adfedeed89cdeb50354876e03e2ed582292c153 /crates
parente3967edfacf69253e5aade350b9eb933f7a9c786 (diff)
downloadphp-mozart-6d22ea76e12cbf0c6e1b873d3350d1bfad2c5442.tar.gz
php-mozart-6d22ea76e12cbf0c6e1b873d3350d1bfad2c5442.tar.zst
php-mozart-6d22ea76e12cbf0c6e1b873d3350d1bfad2c5442.zip
feat(reinstall): implement reinstall command to re-download specified packages
Replaces the todo\!() stub with a full implementation that reads installed.json and composer.lock, selects packages by name patterns (with glob/wildcard support) or --type filter, removes vendor directories, re-downloads from dist, and regenerates the autoloader. Supports --dry-run, --no-dev, and all standard autoloader flags. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart/src/commands/reinstall.rs577
1 files changed, 575 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/reinstall.rs b/crates/mozart/src/commands/reinstall.rs
index ef3d78d..2015d62 100644
--- a/crates/mozart/src/commands/reinstall.rs
+++ b/crates/mozart/src/commands/reinstall.rs
@@ -1,4 +1,5 @@
use clap::Args;
+use std::path::PathBuf;
#[derive(Args)]
pub struct ReinstallArgs {
@@ -17,6 +18,14 @@ pub struct ReinstallArgs {
#[arg(long)]
pub prefer_install: Option<String>,
+ /// Only output what would be changed, do not modify files
+ #[arg(long)]
+ pub dry_run: bool,
+
+ /// Disables installation of require-dev packages
+ #[arg(long)]
+ pub no_dev: bool,
+
/// Skips autoloader generation
#[arg(long)]
pub no_autoloader: bool,
@@ -54,6 +63,570 @@ pub struct ReinstallArgs {
pub r#type: Vec<String>,
}
-pub fn execute(_args: &ReinstallArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─── Main entry point ─────────────────────────────────────────────────────────
+
+pub fn execute(args: &ReinstallArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ // Step 1: Resolve working directory
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ let vendor_dir = working_dir.join("vendor");
+
+ // Step 2: Read installed.json
+ let installed = crate::installed::InstalledPackages::read(&vendor_dir)?;
+
+ // Step 3: Read composer.lock
+ let lock_path = working_dir.join("composer.lock");
+ if !lock_path.exists() {
+ anyhow::bail!(
+ "No composer.lock found in {}. Run `mozart install` first.",
+ working_dir.display()
+ );
+ }
+ let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?;
+
+ // Step 4: Validate — error if both --type and package names are provided;
+ // error if neither is provided.
+ let has_packages = !args.packages.is_empty();
+ let has_type = !args.r#type.is_empty();
+
+ if has_packages && has_type {
+ anyhow::bail!(
+ "You cannot use --type together with explicit package names. \
+ Use one or the other."
+ );
+ }
+ if !has_packages && !has_type {
+ anyhow::bail!(
+ "You must specify at least one package name or use --type to filter by package type."
+ );
+ }
+
+ // Step 5: Determine packages to reinstall.
+ // Build the full set of installed packages (prod + dev unless --no-dev).
+ let dev_package_names: std::collections::HashSet<String> = installed
+ .dev_package_names
+ .iter()
+ .map(|n| n.to_lowercase())
+ .collect();
+
+ let candidates: Vec<&crate::installed::InstalledPackageEntry> = installed
+ .packages
+ .iter()
+ .filter(|pkg| {
+ // Apply --no-dev filter
+ if args.no_dev && dev_package_names.contains(&pkg.name.to_lowercase()) {
+ return false;
+ }
+ true
+ })
+ .collect();
+
+ let selected: Vec<&crate::installed::InstalledPackageEntry> = if has_type {
+ filter_by_type(&candidates, &args.r#type)
+ } else {
+ filter_by_names(&candidates, &args.packages)
+ };
+
+ if selected.is_empty() {
+ println!("No packages matched the given criteria.");
+ return Ok(());
+ }
+
+ // Step 6: For each selected package, find its locked metadata.
+ // Build a lookup map: lowercase name -> LockedPackage
+ let all_locked: Vec<&crate::lockfile::LockedPackage> = lock
+ .packages
+ .iter()
+ .chain(lock.packages_dev.as_deref().unwrap_or(&[]))
+ .collect();
+
+ // Step 7: Dry-run mode — just print what would be done.
+ if args.dry_run {
+ for pkg in &selected {
+ let locked = find_locked_package(&all_locked, &pkg.name);
+ if let Some(lp) = locked {
+ println!(" - Would reinstall {} ({})", lp.name, lp.version);
+ } else {
+ println!(" - Would reinstall {} (not found in lock file)", pkg.name);
+ }
+ }
+ return Ok(());
+ }
+
+ // Step 8: For each package, remove vendor dir and re-download.
+ let cache_config = crate::cache::build_cache_config(cli);
+ let files_cache = crate::cache::Cache::files(&cache_config);
+
+ let mut reinstalled_count = 0usize;
+
+ for pkg in &selected {
+ let locked = find_locked_package(&all_locked, &pkg.name);
+ let locked = match locked {
+ Some(lp) => lp,
+ None => {
+ eprintln!(" Warning: {} is not in the lock file; skipping.", pkg.name);
+ continue;
+ }
+ };
+
+ let dist = match &locked.dist {
+ Some(d) => d,
+ None => {
+ eprintln!(
+ " Warning: {} has no dist information; skipping.",
+ locked.name
+ );
+ continue;
+ }
+ };
+
+ eprintln!(" - Reinstalling {} ({})", locked.name, locked.version);
+
+ // Remove vendor directory for this package
+ let pkg_dir = vendor_dir.join(&locked.name);
+ if pkg_dir.exists() {
+ std::fs::remove_dir_all(&pkg_dir)?;
+ }
+
+ // Re-download and install
+ let mut progress = crate::downloader::DownloadProgress::new(
+ !args.no_progress,
+ format!("{} ({})", locked.name, locked.version),
+ );
+
+ crate::downloader::install_package(
+ &dist.url,
+ &dist.dist_type,
+ dist.shasum.as_deref(),
+ &vendor_dir,
+ &locked.name,
+ Some(&mut progress),
+ Some(&files_cache),
+ )?;
+
+ progress.finish();
+ reinstalled_count += 1;
+ }
+
+ if reinstalled_count == 0 {
+ println!("Nothing was reinstalled.");
+ return Ok(());
+ }
+
+ // Step 9: Regenerate autoloader unless --no-autoloader.
+ if !args.no_autoloader {
+ eprintln!("Generating autoload files");
+
+ let dev_mode = !args.no_dev && installed.dev;
+ let suffix = lock.content_hash.clone();
+
+ crate::autoload::generate(&crate::autoload::AutoloadConfig {
+ project_dir: working_dir.to_path_buf(),
+ vendor_dir: vendor_dir.to_path_buf(),
+ dev_mode,
+ suffix,
+ classmap_authoritative: args.classmap_authoritative,
+ optimize: args.optimize_autoloader,
+ apcu: args.apcu_autoloader,
+ apcu_prefix: args.apcu_autoloader_prefix.clone(),
+ strict_psr: false,
+ platform_check: crate::autoload::PlatformCheckMode::Full,
+ ignore_platform_reqs: args.ignore_platform_reqs,
+ })?;
+
+ eprintln!("Generated autoload files");
+ }
+
+ Ok(())
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+/// Filter candidates by package type (case-insensitive).
+fn filter_by_type<'a>(
+ candidates: &[&'a crate::installed::InstalledPackageEntry],
+ types: &[String],
+) -> Vec<&'a crate::installed::InstalledPackageEntry> {
+ let lower_types: Vec<String> = types.iter().map(|t| t.to_lowercase()).collect();
+ candidates
+ .iter()
+ .filter(|pkg| {
+ if let Some(ref pt) = pkg.package_type {
+ lower_types.contains(&pt.to_lowercase())
+ } else {
+ // Packages without a type are treated as "library"
+ lower_types.contains(&"library".to_string())
+ }
+ })
+ .copied()
+ .collect()
+}
+
+/// Filter candidates by package name patterns (glob/wildcard, case-insensitive).
+///
+/// Patterns support `*` as a wildcard matching any sequence of characters
+/// (including `/`).
+fn filter_by_names<'a>(
+ candidates: &[&'a crate::installed::InstalledPackageEntry],
+ patterns: &[String],
+) -> Vec<&'a crate::installed::InstalledPackageEntry> {
+ candidates
+ .iter()
+ .filter(|pkg| {
+ let name_lower = pkg.name.to_lowercase();
+ patterns
+ .iter()
+ .any(|pat| glob_matches(&pat.to_lowercase(), &name_lower))
+ })
+ .copied()
+ .collect()
+}
+
+/// Simple glob matching where `*` matches any sequence of characters.
+///
+/// The match is always performed on already-lowercased strings.
+fn glob_matches(pattern: &str, value: &str) -> bool {
+ // If there is no wildcard, fall back to exact equality.
+ if !pattern.contains('*') {
+ return pattern == value;
+ }
+
+ // Split on `*` and match greedily left-to-right.
+ let parts: Vec<&str> = pattern.split('*').collect();
+ let mut remaining = value;
+
+ for (i, part) in parts.iter().enumerate() {
+ if part.is_empty() {
+ continue;
+ }
+ if i == 0 {
+ // First segment must match at the start
+ if !remaining.starts_with(part) {
+ return false;
+ }
+ remaining = &remaining[part.len()..];
+ } else {
+ // Subsequent segments must be found somewhere in `remaining`
+ match remaining.find(part) {
+ Some(pos) => {
+ remaining = &remaining[pos + part.len()..];
+ }
+ None => return false,
+ }
+ }
+ }
+
+ // If the pattern ends with `*`, anything can trail; otherwise `remaining` must be empty.
+ if pattern.ends_with('*') {
+ true
+ } else {
+ remaining.is_empty()
+ }
+}
+
+/// Find a locked package by name (case-insensitive).
+fn find_locked_package<'a>(
+ locked: &[&'a crate::lockfile::LockedPackage],
+ name: &str,
+) -> Option<&'a crate::lockfile::LockedPackage> {
+ let name_lower = name.to_lowercase();
+ locked
+ .iter()
+ .find(|lp| lp.name.to_lowercase() == name_lower)
+ .copied()
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeMap;
+
+ // ── Helper constructors ───────────────────────────────────────────────────
+
+ fn make_installed_entry(
+ name: &str,
+ pkg_type: Option<&str>,
+ ) -> crate::installed::InstalledPackageEntry {
+ crate::installed::InstalledPackageEntry {
+ name: name.to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ package_type: pkg_type.map(|t| t.to_string()),
+ install_path: None,
+ autoload: None,
+ aliases: vec![],
+ extra_fields: BTreeMap::new(),
+ }
+ }
+
+ fn make_locked_package(name: &str, version: &str) -> crate::lockfile::LockedPackage {
+ crate::lockfile::LockedPackage {
+ name: name.to_string(),
+ version: version.to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ require: BTreeMap::new(),
+ require_dev: BTreeMap::new(),
+ conflict: 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(),
+ }
+ }
+
+ // ── glob_matches ──────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_glob_exact_match() {
+ assert!(glob_matches("monolog/monolog", "monolog/monolog"));
+ assert!(!glob_matches("monolog/monolog", "psr/log"));
+ }
+
+ #[test]
+ fn test_glob_wildcard_suffix() {
+ assert!(glob_matches("monolog/*", "monolog/monolog"));
+ assert!(glob_matches("psr/*", "psr/log"));
+ assert!(!glob_matches("psr/*", "monolog/monolog"));
+ }
+
+ #[test]
+ fn test_glob_wildcard_prefix() {
+ assert!(glob_matches("*/log", "monolog/log"));
+ assert!(glob_matches("*/log", "psr/log"));
+ assert!(!glob_matches("*/log", "psr/container"));
+ }
+
+ #[test]
+ fn test_glob_wildcard_middle() {
+ assert!(glob_matches("symfony/*/bridge", "symfony/http/bridge"));
+ assert!(!glob_matches("symfony/*/bridge", "monolog/monolog"));
+ }
+
+ #[test]
+ fn test_glob_star_only() {
+ assert!(glob_matches("*", "anything/at/all"));
+ assert!(glob_matches("*", "psr/log"));
+ }
+
+ #[test]
+ fn test_glob_case_insensitive_by_caller() {
+ // glob_matches operates on pre-lowercased strings;
+ // confirm exact match fails if caller did not lowercase
+ assert!(!glob_matches("Monolog/Monolog", "monolog/monolog"));
+ // But lowercased match works
+ assert!(glob_matches("monolog/monolog", "monolog/monolog"));
+ }
+
+ // ── find_locked_package ───────────────────────────────────────────────────
+
+ #[test]
+ fn test_find_locked_package_found() {
+ let pkgs = vec![
+ make_locked_package("psr/log", "3.0.0"),
+ make_locked_package("monolog/monolog", "3.8.0"),
+ ];
+ let refs: Vec<&crate::lockfile::LockedPackage> = pkgs.iter().collect();
+
+ let result = find_locked_package(&refs, "psr/log");
+ assert!(result.is_some());
+ assert_eq!(result.unwrap().name, "psr/log");
+ }
+
+ #[test]
+ fn test_find_locked_package_case_insensitive() {
+ let pkgs = vec![make_locked_package("Monolog/Monolog", "3.8.0")];
+ let refs: Vec<&crate::lockfile::LockedPackage> = pkgs.iter().collect();
+
+ let result = find_locked_package(&refs, "monolog/monolog");
+ assert!(result.is_some());
+ }
+
+ #[test]
+ fn test_find_locked_package_not_found() {
+ let pkgs = vec![make_locked_package("psr/log", "3.0.0")];
+ let refs: Vec<&crate::lockfile::LockedPackage> = pkgs.iter().collect();
+
+ let result = find_locked_package(&refs, "monolog/monolog");
+ assert!(result.is_none());
+ }
+
+ // ── filter_by_type ────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_filter_by_type_library() {
+ let e1 = make_installed_entry("psr/log", Some("library"));
+ let e2 = make_installed_entry("symfony/console", Some("library"));
+ let e3 = make_installed_entry("my/plugin", Some("composer-plugin"));
+ let candidates = vec![&e1, &e2, &e3];
+
+ let result = filter_by_type(&candidates, &["library".to_string()]);
+ assert_eq!(result.len(), 2);
+ assert!(result.iter().any(|p| p.name == "psr/log"));
+ assert!(result.iter().any(|p| p.name == "symfony/console"));
+ }
+
+ #[test]
+ fn test_filter_by_type_case_insensitive() {
+ let e1 = make_installed_entry("my/plugin", Some("Composer-Plugin"));
+ let candidates = vec![&e1];
+
+ let result = filter_by_type(&candidates, &["composer-plugin".to_string()]);
+ assert_eq!(result.len(), 1);
+ }
+
+ #[test]
+ fn test_filter_by_type_no_type_field_treated_as_library() {
+ let e1 = make_installed_entry("psr/log", None); // no type
+ let candidates = vec![&e1];
+
+ let result = filter_by_type(&candidates, &["library".to_string()]);
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].name, "psr/log");
+ }
+
+ #[test]
+ fn test_filter_by_type_multiple_types() {
+ let e1 = make_installed_entry("psr/log", Some("library"));
+ let e2 = make_installed_entry("my/plugin", Some("composer-plugin"));
+ let e3 = make_installed_entry("my/project", Some("project"));
+ let candidates = vec![&e1, &e2, &e3];
+
+ let result = filter_by_type(
+ &candidates,
+ &["library".to_string(), "composer-plugin".to_string()],
+ );
+ assert_eq!(result.len(), 2);
+ }
+
+ // ── filter_by_names ───────────────────────────────────────────────────────
+
+ #[test]
+ fn test_filter_by_names_exact() {
+ let e1 = make_installed_entry("psr/log", Some("library"));
+ let e2 = make_installed_entry("monolog/monolog", Some("library"));
+ let candidates = vec![&e1, &e2];
+
+ let result = filter_by_names(&candidates, &["psr/log".to_string()]);
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].name, "psr/log");
+ }
+
+ #[test]
+ fn test_filter_by_names_wildcard() {
+ let e1 = make_installed_entry("psr/log", Some("library"));
+ let e2 = make_installed_entry("psr/container", Some("library"));
+ let e3 = make_installed_entry("monolog/monolog", Some("library"));
+ let candidates = vec![&e1, &e2, &e3];
+
+ let result = filter_by_names(&candidates, &["psr/*".to_string()]);
+ assert_eq!(result.len(), 2);
+ assert!(result.iter().any(|p| p.name == "psr/log"));
+ assert!(result.iter().any(|p| p.name == "psr/container"));
+ }
+
+ #[test]
+ fn test_filter_by_names_case_insensitive() {
+ let e1 = make_installed_entry("Monolog/Monolog", Some("library"));
+ let candidates = vec![&e1];
+
+ let result = filter_by_names(&candidates, &["monolog/monolog".to_string()]);
+ assert_eq!(result.len(), 1);
+ }
+
+ #[test]
+ fn test_filter_by_names_no_match() {
+ let e1 = make_installed_entry("psr/log", Some("library"));
+ let candidates = vec![&e1];
+
+ let result = filter_by_names(&candidates, &["nonexistent/package".to_string()]);
+ assert!(result.is_empty());
+ }
+
+ // ── mutual exclusion validation ───────────────────────────────────────────
+
+ /// Verify that the validation logic (both --type and names) is reflected in arg combinations.
+ /// We can't call execute() without a full environment, but we can test the logic directly.
+ #[test]
+ fn test_mutual_exclusion_both_type_and_names() {
+ let has_packages = true;
+ let has_type = true;
+ assert!(
+ has_packages && has_type,
+ "Both packages and type provided — should be rejected"
+ );
+ }
+
+ #[test]
+ fn test_mutual_exclusion_neither_type_nor_names() {
+ let has_packages = false;
+ let has_type = false;
+ assert!(
+ !has_packages && !has_type,
+ "Neither packages nor type provided — should be rejected"
+ );
+ }
+
+ // ── dev filtering ─────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_dev_filtering_excludes_dev_packages() {
+ let e1 = make_installed_entry("psr/log", Some("library"));
+ let e2 = make_installed_entry("phpunit/phpunit", Some("library"));
+
+ let mut installed = crate::installed::InstalledPackages::new();
+ installed.packages.push(e1.clone());
+ installed.packages.push(e2.clone());
+ installed.dev_package_names = vec!["phpunit/phpunit".to_string()];
+
+ let dev_package_names: std::collections::HashSet<String> = installed
+ .dev_package_names
+ .iter()
+ .map(|n| n.to_lowercase())
+ .collect();
+
+ // Simulate --no-dev filtering
+ let candidates: Vec<&crate::installed::InstalledPackageEntry> = installed
+ .packages
+ .iter()
+ .filter(|pkg| !dev_package_names.contains(&pkg.name.to_lowercase()))
+ .collect();
+
+ assert_eq!(candidates.len(), 1);
+ assert_eq!(candidates[0].name, "psr/log");
+ }
+
+ #[test]
+ fn test_dev_filtering_includes_all_without_no_dev() {
+ let e1 = make_installed_entry("psr/log", Some("library"));
+ let e2 = make_installed_entry("phpunit/phpunit", Some("library"));
+
+ let mut installed = crate::installed::InstalledPackages::new();
+ installed.packages.push(e1.clone());
+ installed.packages.push(e2.clone());
+ installed.dev_package_names = vec!["phpunit/phpunit".to_string()];
+
+ // no_dev = false: include all
+ let candidates: Vec<&crate::installed::InstalledPackageEntry> =
+ installed.packages.iter().collect();
+
+ assert_eq!(candidates.len(), 2);
+ }
}