aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 12:50:15 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 12:50:15 +0900
commit96f94253d66eb9302855d7a6ae4534e12d818d58 (patch)
tree24340724112f439ca49c48c1cfcb138b01b623f4 /crates
parent70881be20ebedad2834566065444f76a67e7cc8c (diff)
downloadphp-mozart-96f94253d66eb9302855d7a6ae4534e12d818d58.tar.gz
php-mozart-96f94253d66eb9302855d7a6ae4534e12d818d58.tar.zst
php-mozart-96f94253d66eb9302855d7a6ae4534e12d818d58.zip
feat(update): implement update command with resolver-based dependency updates
Add full update command supporting --lock (content-hash refresh only), --dry-run, --no-install, --no-dev, --prefer-stable, --prefer-lowest, and partial updates (named packages). Extract install_from_lock() from install.rs for shared use. Add Stability::parse() to package.rs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart/src/commands/install.rs217
-rw-r--r--crates/mozart/src/commands/update.rs964
-rw-r--r--crates/mozart/src/package.rs16
3 files changed, 1102 insertions, 95 deletions
diff --git a/crates/mozart/src/commands/install.rs b/crates/mozart/src/commands/install.rs
index 97f42e7..c652569 100644
--- a/crates/mozart/src/commands/install.rs
+++ b/crates/mozart/src/commands/install.rs
@@ -107,7 +107,7 @@ pub struct InstallOp<'a> {
}
/// Resolve the working directory from the CLI option, falling back to cwd.
-fn resolve_working_dir(cli: &super::Cli) -> PathBuf {
+pub 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"),
@@ -181,7 +181,7 @@ pub fn locked_to_installed_entry(
}
/// Clean up empty vendor namespace directories after removals.
-fn cleanup_empty_vendor_dirs(vendor_dir: &Path) -> anyhow::Result<()> {
+pub 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();
@@ -201,78 +201,28 @@ fn cleanup_empty_vendor_dirs(vendor_dir: &Path) -> anyhow::Result<()> {
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;
-
+/// Install packages from a lock file into vendor/.
+///
+/// Used by both the `install` and `update` commands.
+///
+/// This function:
+/// 1. Determines which packages to install (prod + optionally dev)
+/// 2. Reads currently installed packages
+/// 3. Computes install/update/skip/removal operations
+/// 4. Prints a summary
+/// 5. Executes downloads and removals (unless dry_run)
+/// 6. Writes vendor/composer/installed.json
+/// 7. Cleans up empty vendor directories
+/// 8. Generates the autoloader (unless no_autoloader)
+pub fn install_from_lock(
+ lock: &lockfile::LockFile,
+ working_dir: &Path,
+ vendor_dir: &Path,
+ dev_mode: bool,
+ dry_run: bool,
+ no_autoloader: bool,
+) -> anyhow::Result<()> {
+ // Step 1: Determine which packages to install
let mut packages_to_install: Vec<&lockfile::LockedPackage> = lock.packages.iter().collect();
if dev_mode && let Some(ref dev_pkgs) = lock.packages_dev {
@@ -287,16 +237,13 @@ pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> {
}
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 2: Read currently installed packages
+ let installed = installed::InstalledPackages::read(vendor_dir)?;
- // Step 8: Compute install operations
+ // Step 3: Compute install operations
let (ops, removals) = compute_operations(&packages_to_install, &installed);
- // Step 9: Print operation summary
+ // Step 4: Print operation summary
let installs: Vec<_> = ops
.iter()
.filter(|(_, a)| matches!(a, Action::Install))
@@ -323,8 +270,8 @@ pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> {
);
}
- // Step 10: Execute operations (unless --dry-run)
- if args.dry_run {
+ // Step 5: Execute operations (unless dry_run)
+ if dry_run {
for (pkg, action) in &ops {
match action {
Action::Skip => {}
@@ -362,7 +309,7 @@ pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> {
&dist.url,
&dist.dist_type,
dist.shasum.as_deref(),
- &vendor_dir,
+ vendor_dir,
&pkg.name,
)?;
}
@@ -376,12 +323,12 @@ pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> {
}
}
- // Step 13: Clean up empty vendor namespace directories
+ // Step 6: Clean up empty vendor namespace directories
if !removals.is_empty() {
- cleanup_empty_vendor_dirs(&vendor_dir)?;
+ cleanup_empty_vendor_dirs(vendor_dir)?;
}
- // Step 11: Write updated vendor/composer/installed.json
+ // Step 7: Write updated vendor/composer/installed.json
let mut new_installed = installed::InstalledPackages::new();
new_installed.dev = dev_mode;
@@ -391,20 +338,20 @@ pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> {
}
for pkg in &packages_to_install {
- new_installed.upsert(locked_to_installed_entry(pkg, &vendor_dir));
+ new_installed.upsert(locked_to_installed_entry(pkg, vendor_dir));
}
- new_installed.write(&vendor_dir)?;
+ new_installed.write(vendor_dir)?;
- // Step 14: Generate autoloader (unless --no-autoloader)
- if !args.no_autoloader {
+ // Step 8: Generate autoloader (unless no_autoloader)
+ if !no_autoloader {
eprintln!("Generating autoload files");
let suffix = lock.content_hash.clone();
crate::autoload::generate(&crate::autoload::AutoloadConfig {
- project_dir: working_dir.clone(),
- vendor_dir: vendor_dir.clone(),
+ project_dir: working_dir.to_path_buf(),
+ vendor_dir: vendor_dir.to_path_buf(),
dev_mode,
suffix,
})?;
@@ -416,6 +363,90 @@ pub fn execute(args: &InstallArgs, cli: &super::Cli) -> anyhow::Result<()> {
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 dev mode and vendor directory
+ let dev_mode = !args.no_dev;
+ let vendor_dir = working_dir.join("vendor");
+
+ // Step 6: Delegate to shared install_from_lock()
+ install_from_lock(
+ &lock,
+ &working_dir,
+ &vendor_dir,
+ dev_mode,
+ args.dry_run,
+ args.no_autoloader,
+ )
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs
index 7cbfd74..36260a0 100644
--- a/crates/mozart/src/commands/update.rs
+++ b/crates/mozart/src/commands/update.rs
@@ -1,4 +1,9 @@
+use crate::console;
+use crate::lockfile;
+use crate::package::{self, Stability};
+use crate::resolver::{self, PlatformConfig, ResolveRequest, ResolvedPackage};
use clap::Args;
+use std::collections::HashMap;
#[derive(Args)]
pub struct UpdateArgs {
@@ -126,6 +131,961 @@ pub struct UpdateArgs {
pub bump_after_update: Option<Option<String>>,
}
-pub fn execute(_args: &UpdateArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─────────────────────────────────────────────────────────────────────────────
+// Change tracking types
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// The kind of change for a package during update.
+#[derive(Debug, PartialEq, Eq)]
+pub enum ChangeKind {
+ Install {
+ new_version: String,
+ },
+ Update {
+ old_version: String,
+ new_version: String,
+ },
+ Remove {
+ old_version: String,
+ },
+ Unchanged,
+}
+
+/// A single package change entry computed during update.
+#[derive(Debug)]
+pub struct UpdateChange {
+ pub name: String,
+ pub kind: ChangeKind,
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helper: parse minimum-stability string
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Parse a minimum-stability string from composer.json into a `Stability` enum value.
+///
+/// Recognizes "stable", "RC", "beta", "alpha", "dev" (case-insensitive).
+/// Defaults to `Stability::Stable` for unrecognized values.
+fn parse_minimum_stability(s: &str) -> Stability {
+ package::Stability::parse(s)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helper: compute changes between old and new lock
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Compare old lock vs new lock to determine installs, updates, removals, and unchanged packages.
+///
+/// Produces one `UpdateChange` per affected package. Packages that are identical in both
+/// lock files are omitted (ChangeKind::Unchanged) from the returned vec — callers that
+/// want the full list should filter as needed.
+pub fn compute_update_changes(
+ old_lock: Option<&lockfile::LockFile>,
+ new_lock: &lockfile::LockFile,
+ dev_mode: bool,
+) -> Vec<UpdateChange> {
+ // Build map of old lock packages keyed by lowercase name -> version string
+ let mut old_map: HashMap<String, String> = HashMap::new();
+ if let Some(old) = old_lock {
+ for pkg in &old.packages {
+ old_map.insert(pkg.name.to_lowercase(), pkg.version.clone());
+ }
+ if dev_mode && let Some(ref dev_pkgs) = old.packages_dev {
+ for pkg in dev_pkgs {
+ old_map.insert(pkg.name.to_lowercase(), pkg.version.clone());
+ }
+ }
+ }
+
+ // Build map of new lock packages keyed by lowercase name -> version string
+ let mut new_map: HashMap<String, String> = HashMap::new();
+ for pkg in &new_lock.packages {
+ new_map.insert(pkg.name.to_lowercase(), pkg.version.clone());
+ }
+ if dev_mode && let Some(ref dev_pkgs) = new_lock.packages_dev {
+ for pkg in dev_pkgs {
+ new_map.insert(pkg.name.to_lowercase(), pkg.version.clone());
+ }
+ }
+
+ let mut changes: Vec<UpdateChange> = Vec::new();
+
+ // Check all packages in the new lock
+ for (name, new_version) in &new_map {
+ let kind = if let Some(old_version) = old_map.get(name) {
+ if old_version == new_version {
+ ChangeKind::Unchanged
+ } else {
+ ChangeKind::Update {
+ old_version: old_version.clone(),
+ new_version: new_version.clone(),
+ }
+ }
+ } else {
+ ChangeKind::Install {
+ new_version: new_version.clone(),
+ }
+ };
+
+ if !matches!(kind, ChangeKind::Unchanged) {
+ changes.push(UpdateChange {
+ name: name.clone(),
+ kind,
+ });
+ }
+ }
+
+ // Check packages in the old lock that are missing from the new lock (removals)
+ for (name, old_version) in &old_map {
+ if !new_map.contains_key(name) {
+ changes.push(UpdateChange {
+ name: name.clone(),
+ kind: ChangeKind::Remove {
+ old_version: old_version.clone(),
+ },
+ });
+ }
+ }
+
+ changes
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Helper: apply partial update filter
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// For a partial update (when specific packages are named on the CLI), swap back
+/// the versions of packages that were NOT requested to be updated.
+///
+/// This implements the simplified approach (plan approach c):
+/// - For packages in `update_packages`: use the newly resolved version.
+/// - For packages NOT in `update_packages`: if the old lock has them at a different
+/// version, keep the old locked version. This prevents unintended upgrades.
+///
+/// Note: This is a best-effort approach. In some cases the resolver may pick
+/// incompatible versions; a future phase can add true pinning via resolver constraints.
+pub fn apply_partial_update(
+ resolved: Vec<ResolvedPackage>,
+ old_lock: &lockfile::LockFile,
+ update_packages: &[String],
+) -> Vec<ResolvedPackage> {
+ // Build a set of normalized package names we want to update
+ let update_set: std::collections::HashSet<String> =
+ update_packages.iter().map(|s| s.to_lowercase()).collect();
+
+ // Build a map of old locked packages by name -> (version, version_normalized, is_dev)
+ let mut old_pkg_map: HashMap<String, &lockfile::LockedPackage> = HashMap::new();
+ for pkg in &old_lock.packages {
+ old_pkg_map.insert(pkg.name.to_lowercase(), pkg);
+ }
+ if let Some(ref dev_pkgs) = old_lock.packages_dev {
+ for pkg in dev_pkgs {
+ old_pkg_map.insert(pkg.name.to_lowercase(), pkg);
+ }
+ }
+
+ resolved
+ .into_iter()
+ .map(|mut pkg| {
+ let name_lower = pkg.name.to_lowercase();
+ // If this package is NOT in the update set and we have an old locked version,
+ // swap it back to the old version to prevent unintended changes.
+ if !update_set.contains(&name_lower)
+ && let Some(old_pkg) = old_pkg_map.get(&name_lower)
+ {
+ pkg.version = old_pkg.version.clone();
+ pkg.version_normalized = old_pkg
+ .version_normalized
+ .clone()
+ .unwrap_or_else(|| old_pkg.version.clone());
+ pkg.is_dev = false; // preserve existing; lock file doesn't store this flag directly
+ }
+ pkg
+ })
+ .collect()
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Main execute function
+// ─────────────────────────────────────────────────────────────────────────────
+
+pub fn execute(args: &UpdateArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ // Step 1: Resolve the working directory
+ let working_dir = super::install::resolve_working_dir(cli);
+
+ // Step 2: Handle deprecated flags
+ if args.dev {
+ eprintln!(
+ "{}",
+ console::warning(
+ "The --dev option is deprecated. Dev packages are updated by default."
+ )
+ );
+ }
+ if args.no_suggest {
+ eprintln!(
+ "{}",
+ console::warning("The --no-suggest option is deprecated and has no effect.")
+ );
+ }
+
+ // Warn about deferred flags
+ if args.with_dependencies || args.with_all_dependencies {
+ eprintln!(
+ "{}",
+ console::warning(
+ "--with-dependencies / --with-all-dependencies are not yet implemented and will be ignored."
+ )
+ );
+ }
+ if args.minimal_changes {
+ eprintln!(
+ "{}",
+ console::warning("--minimal-changes is not yet implemented and will be ignored.")
+ );
+ }
+ if args.patch_only {
+ eprintln!(
+ "{}",
+ console::warning("--patch-only is not yet implemented and will be ignored.")
+ );
+ }
+ if args.interactive {
+ eprintln!(
+ "{}",
+ console::warning("--interactive is not yet implemented and will be ignored.")
+ );
+ }
+ if args.root_reqs {
+ eprintln!(
+ "{}",
+ console::warning("--root-reqs is not yet implemented and will be ignored.")
+ );
+ }
+ if args.bump_after_update.is_some() {
+ eprintln!(
+ "{}",
+ console::warning("--bump-after-update is not yet implemented and will be ignored.")
+ );
+ }
+
+ // Step 3: Read composer.json
+ let composer_json_path = working_dir.join("composer.json");
+ if !composer_json_path.exists() {
+ eprintln!(
+ "{}",
+ console::error(&format!(
+ "Composer could not find a composer.json file in {}",
+ working_dir.display()
+ ))
+ );
+ std::process::exit(1);
+ }
+ let composer_json = package::read_from_file(&composer_json_path)?;
+ let composer_json_content = std::fs::read_to_string(&composer_json_path)?;
+
+ let lock_path = working_dir.join("composer.lock");
+ let vendor_dir = working_dir.join("vendor");
+
+ // Step 4: Handle --lock mode (early return)
+ if args.lock {
+ return handle_lock_mode(&lock_path, &composer_json_content, args.dry_run);
+ }
+
+ let dev_mode = !args.no_dev;
+
+ // Step 5: Build the resolve request from composer.json
+ // Filter out platform packages from require list for the resolver (they're handled separately)
+ let require: Vec<(String, String)> = composer_json
+ .require
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+
+ let require_dev: Vec<(String, String)> = composer_json
+ .require_dev
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+
+ // Parse minimum-stability from composer.json (defaults to "stable")
+ let minimum_stability_str = composer_json
+ .minimum_stability
+ .as_deref()
+ .unwrap_or("stable");
+ let minimum_stability = parse_minimum_stability(minimum_stability_str);
+
+ // Determine prefer-stable: CLI flag OR composer.json field
+ let composer_prefer_stable = composer_json
+ .extra_fields
+ .get("prefer-stable")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let prefer_stable = args.prefer_stable || composer_prefer_stable;
+
+ let request = ResolveRequest {
+ require,
+ require_dev,
+ include_dev: dev_mode,
+ minimum_stability,
+ stability_flags: HashMap::new(),
+ prefer_stable,
+ prefer_lowest: args.prefer_lowest,
+ platform: PlatformConfig::new(),
+ ignore_platform_reqs: args.ignore_platform_reqs,
+ ignore_platform_req_list: args.ignore_platform_req.clone(),
+ };
+
+ // Step 6: Print header and run resolver
+ eprintln!("Loading composer repositories with package information");
+ if dev_mode {
+ eprintln!("Updating dependencies (including require-dev)");
+ } else {
+ eprintln!("Updating dependencies");
+ }
+ eprintln!("Resolving dependencies...");
+
+ let mut resolved = match resolver::resolve(&request) {
+ Ok(packages) => packages,
+ Err(e) => {
+ eprintln!("{}", console::error(&e.to_string()));
+ std::process::exit(1);
+ }
+ };
+
+ // Step 7: Read old lock file (for change reporting and partial update)
+ let old_lock = if lock_path.exists() {
+ match lockfile::LockFile::read_from_file(&lock_path) {
+ Ok(l) => Some(l),
+ Err(e) => {
+ eprintln!(
+ "{}",
+ console::warning(&format!(
+ "Could not read existing composer.lock: {}. Treating as a fresh install.",
+ e
+ ))
+ );
+ None
+ }
+ }
+ } else {
+ None
+ };
+
+ // Step 8: Handle partial update (if specific packages were named)
+ if !args.packages.is_empty() {
+ match &old_lock {
+ None => {
+ eprintln!(
+ "{}",
+ console::error(
+ "No lock file found. Cannot perform partial update. Run `mozart update` first."
+ )
+ );
+ std::process::exit(1);
+ }
+ Some(lock) => {
+ resolved = apply_partial_update(resolved, lock, &args.packages);
+ }
+ }
+ }
+
+ // Step 9: Generate new lock file
+ let new_lock = lockfile::generate_lock_file(&lockfile::LockFileGenerationRequest {
+ resolved_packages: resolved,
+ composer_json_content: composer_json_content.clone(),
+ composer_json: composer_json.clone(),
+ include_dev: dev_mode,
+ })?;
+
+ // Step 10: Compute and print change report
+ let changes = compute_update_changes(old_lock.as_ref(), &new_lock, dev_mode);
+
+ let installs: Vec<_> = changes
+ .iter()
+ .filter(|c| matches!(c.kind, ChangeKind::Install { .. }))
+ .collect();
+ let updates: Vec<_> = changes
+ .iter()
+ .filter(|c| matches!(c.kind, ChangeKind::Update { .. }))
+ .collect();
+ let removals: Vec<_> = changes
+ .iter()
+ .filter(|c| matches!(c.kind, ChangeKind::Remove { .. }))
+ .collect();
+
+ 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" },
+ ))
+ );
+
+ // Print individual change lines
+ let prefix = if args.dry_run { "Would" } else { "" };
+ for change in &changes {
+ match &change.kind {
+ ChangeKind::Remove { old_version } => {
+ if args.dry_run {
+ eprintln!(" - {} remove {} ({})", prefix, change.name, old_version);
+ } else {
+ eprintln!(" - Removing {} ({})", change.name, old_version);
+ }
+ }
+ ChangeKind::Install { new_version } => {
+ if args.dry_run {
+ eprintln!(" - {} install {} ({})", prefix, change.name, new_version);
+ } else {
+ eprintln!(" - Installing {} ({})", change.name, new_version);
+ }
+ }
+ ChangeKind::Update {
+ old_version,
+ new_version,
+ } => {
+ if args.dry_run {
+ eprintln!(
+ " - {} update {} ({} => {})",
+ prefix, change.name, old_version, new_version
+ );
+ } else {
+ eprintln!(
+ " - Updating {} ({} => {})",
+ change.name, old_version, new_version
+ );
+ }
+ }
+ ChangeKind::Unchanged => {}
+ }
+ }
+
+ // Step 11: Write lock file (unless --dry-run)
+ if !args.dry_run {
+ eprintln!("Writing lock file");
+ new_lock.write_to_file(&lock_path)?;
+ }
+
+ // Step 12: Install packages (unless --no-install or --dry-run)
+ if !args.no_install && !args.dry_run {
+ super::install::install_from_lock(
+ &new_lock,
+ &working_dir,
+ &vendor_dir,
+ dev_mode,
+ false, // dry_run already checked above
+ args.no_autoloader,
+ )?;
+ }
+
+ Ok(())
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// --lock mode handler
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Handle the `--lock` mode: refresh the content-hash of the existing lock file.
+///
+/// Reads the existing composer.lock, computes the new content-hash from the current
+/// composer.json, and writes the updated lock file back to disk if the hash differs.
+fn handle_lock_mode(
+ lock_path: &std::path::Path,
+ composer_json_content: &str,
+ dry_run: bool,
+) -> anyhow::Result<()> {
+ if !lock_path.exists() {
+ eprintln!(
+ "{}",
+ console::error("No lock file found. Run `mozart update` to generate one.")
+ );
+ std::process::exit(1);
+ }
+
+ let mut lock = lockfile::LockFile::read_from_file(lock_path)?;
+
+ let new_hash = lockfile::LockFile::compute_content_hash(composer_json_content)?;
+
+ if new_hash == lock.content_hash {
+ eprintln!("Lock file is already up to date");
+ return Ok(());
+ }
+
+ lock.content_hash = new_hash;
+
+ if !dry_run {
+ lock.write_to_file(lock_path)?;
+ eprintln!("Lock file hash updated successfully.");
+ } else {
+ eprintln!("Would update lock file hash.");
+ }
+
+ Ok(())
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeMap;
+
+ // ──────────── Helper constructors ────────────
+
+ fn make_locked_package(name: &str, version: &str) -> lockfile::LockedPackage {
+ lockfile::LockedPackage {
+ name: name.to_string(),
+ version: version.to_string(),
+ version_normalized: Some(format!("{}.0", version)),
+ 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 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()),
+ }
+ }
+
+ fn make_resolved_package(name: &str, version: &str) -> ResolvedPackage {
+ ResolvedPackage {
+ name: name.to_string(),
+ version: version.to_string(),
+ version_normalized: format!("{}.0", version),
+ is_dev: false,
+ }
+ }
+
+ // ──────────── parse_minimum_stability ────────────
+
+ #[test]
+ fn test_parse_minimum_stability_stable() {
+ assert_eq!(parse_minimum_stability("stable"), Stability::Stable);
+ assert_eq!(parse_minimum_stability("STABLE"), Stability::Stable);
+ assert_eq!(parse_minimum_stability("Stable"), Stability::Stable);
+ }
+
+ #[test]
+ fn test_parse_minimum_stability_rc() {
+ assert_eq!(parse_minimum_stability("RC"), Stability::RC);
+ assert_eq!(parse_minimum_stability("rc"), Stability::RC);
+ }
+
+ #[test]
+ fn test_parse_minimum_stability_beta() {
+ assert_eq!(parse_minimum_stability("beta"), Stability::Beta);
+ assert_eq!(parse_minimum_stability("BETA"), Stability::Beta);
+ }
+
+ #[test]
+ fn test_parse_minimum_stability_alpha() {
+ assert_eq!(parse_minimum_stability("alpha"), Stability::Alpha);
+ assert_eq!(parse_minimum_stability("ALPHA"), Stability::Alpha);
+ }
+
+ #[test]
+ fn test_parse_minimum_stability_dev() {
+ assert_eq!(parse_minimum_stability("dev"), Stability::Dev);
+ assert_eq!(parse_minimum_stability("DEV"), Stability::Dev);
+ }
+
+ #[test]
+ fn test_parse_minimum_stability_unknown_defaults_to_stable() {
+ assert_eq!(parse_minimum_stability("unknown"), Stability::Stable);
+ assert_eq!(parse_minimum_stability(""), Stability::Stable);
+ }
+
+ // ──────────── compute_update_changes ────────────
+
+ #[test]
+ fn test_compute_update_changes_all_new() {
+ // No old lock: all packages in new lock should be Install
+ let new_lock = minimal_lock(vec![
+ make_locked_package("psr/log", "3.0.0"),
+ make_locked_package("monolog/monolog", "3.8.0"),
+ ]);
+
+ let changes = compute_update_changes(None, &new_lock, false);
+
+ assert_eq!(changes.len(), 2);
+ for change in &changes {
+ assert!(
+ matches!(change.kind, ChangeKind::Install { .. }),
+ "Expected Install, got {:?} for {}",
+ change.kind,
+ change.name
+ );
+ }
+ }
+
+ #[test]
+ fn test_compute_update_changes_update() {
+ // Old lock has psr/log at 3.0.0; new lock has it at 3.0.1 -> Update
+ let old_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+ let new_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.1")]);
+
+ let changes = compute_update_changes(Some(&old_lock), &new_lock, false);
+
+ assert_eq!(changes.len(), 1);
+ assert_eq!(changes[0].name, "psr/log");
+ assert!(matches!(
+ &changes[0].kind,
+ ChangeKind::Update {
+ old_version,
+ new_version
+ } if old_version == "3.0.0" && new_version == "3.0.1"
+ ));
+ }
+
+ #[test]
+ fn test_compute_update_changes_remove() {
+ // Old lock has monolog; new lock doesn't -> Remove
+ let old_lock = minimal_lock(vec![
+ make_locked_package("psr/log", "3.0.0"),
+ make_locked_package("monolog/monolog", "3.8.0"),
+ ]);
+ let new_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+
+ let changes = compute_update_changes(Some(&old_lock), &new_lock, false);
+
+ assert_eq!(changes.len(), 1);
+ assert_eq!(changes[0].name, "monolog/monolog");
+ assert!(matches!(
+ &changes[0].kind,
+ ChangeKind::Remove { old_version } if old_version == "3.8.0"
+ ));
+ }
+
+ #[test]
+ fn test_compute_update_changes_unchanged_not_in_result() {
+ // Same version in both locks -> no changes
+ let old_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+ let new_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+
+ let changes = compute_update_changes(Some(&old_lock), &new_lock, false);
+
+ assert!(
+ changes.is_empty(),
+ "Unchanged packages should not appear in changes list"
+ );
+ }
+
+ #[test]
+ fn test_compute_update_changes_mixed() {
+ // Mixed scenario: install, update, remove, unchanged
+ let old_lock = minimal_lock(vec![
+ make_locked_package("psr/log", "3.0.0"), // unchanged
+ make_locked_package("monolog/monolog", "3.7.0"), // will be updated
+ make_locked_package("old/package", "1.0.0"), // will be removed
+ ]);
+ let new_lock = minimal_lock(vec![
+ make_locked_package("psr/log", "3.0.0"), // unchanged
+ make_locked_package("monolog/monolog", "3.8.0"), // updated
+ make_locked_package("new/package", "2.0.0"), // installed
+ ]);
+
+ let changes = compute_update_changes(Some(&old_lock), &new_lock, false);
+
+ // 3 changes: update monolog, remove old/package, install new/package
+ assert_eq!(changes.len(), 3);
+
+ let monolog = changes
+ .iter()
+ .find(|c| c.name == "monolog/monolog")
+ .unwrap();
+ assert!(matches!(
+ &monolog.kind,
+ ChangeKind::Update { old_version, new_version }
+ if old_version == "3.7.0" && new_version == "3.8.0"
+ ));
+
+ let removed = changes.iter().find(|c| c.name == "old/package").unwrap();
+ assert!(matches!(&removed.kind, ChangeKind::Remove { .. }));
+
+ let installed = changes.iter().find(|c| c.name == "new/package").unwrap();
+ assert!(matches!(&installed.kind, ChangeKind::Install { .. }));
+ }
+
+ #[test]
+ fn test_compute_update_changes_dev_packages_included() {
+ // dev_mode=true: dev packages are also compared
+ let mut old_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+ old_lock.packages_dev = Some(vec![make_locked_package("phpunit/phpunit", "10.0.0")]);
+
+ let mut new_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+ new_lock.packages_dev = Some(vec![make_locked_package("phpunit/phpunit", "11.0.0")]);
+
+ let changes = compute_update_changes(Some(&old_lock), &new_lock, true);
+
+ assert_eq!(changes.len(), 1);
+ assert_eq!(changes[0].name, "phpunit/phpunit");
+ assert!(matches!(&changes[0].kind, ChangeKind::Update { .. }));
+ }
+
+ #[test]
+ fn test_compute_update_changes_dev_packages_excluded_when_no_dev() {
+ // dev_mode=false: dev packages are ignored
+ let mut old_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+ old_lock.packages_dev = Some(vec![make_locked_package("phpunit/phpunit", "10.0.0")]);
+
+ let mut new_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+ new_lock.packages_dev = Some(vec![make_locked_package("phpunit/phpunit", "11.0.0")]);
+
+ let changes = compute_update_changes(Some(&old_lock), &new_lock, false);
+
+ // No changes because we're not including dev packages
+ assert!(
+ changes.is_empty(),
+ "Dev packages should not appear in changes when dev_mode=false"
+ );
+ }
+
+ // ──────────── apply_partial_update ────────────
+
+ #[test]
+ fn test_apply_partial_update_keeps_non_specified_packages() {
+ // old lock has psr/log 3.0.0 and monolog 3.7.0
+ // resolver found psr/log 3.0.1 and monolog 3.8.0
+ // we only want to update monolog
+ // expected: psr/log stays at 3.0.0, monolog becomes 3.8.0
+
+ let old_lock = minimal_lock(vec![
+ make_locked_package("psr/log", "3.0.0"),
+ make_locked_package("monolog/monolog", "3.7.0"),
+ ]);
+
+ let resolved = vec![
+ make_resolved_package("psr/log", "3.0.1"),
+ make_resolved_package("monolog/monolog", "3.8.0"),
+ ];
+
+ let update_packages = vec!["monolog/monolog".to_string()];
+ let result = apply_partial_update(resolved, &old_lock, &update_packages);
+
+ let psr = result.iter().find(|p| p.name == "psr/log").unwrap();
+ assert_eq!(
+ psr.version, "3.0.0",
+ "psr/log should be kept at old version"
+ );
+
+ let monolog = result.iter().find(|p| p.name == "monolog/monolog").unwrap();
+ assert_eq!(
+ monolog.version, "3.8.0",
+ "monolog/monolog should use new version"
+ );
+ }
+
+ #[test]
+ fn test_apply_partial_update_case_insensitive() {
+ // update_packages uses mixed case, package names may be lowercase
+ let old_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+ let resolved = vec![make_resolved_package("psr/log", "3.0.1")];
+
+ // Not updating psr/log (not in update list); should revert to 3.0.0
+ let update_packages = vec!["MonoLog/Monolog".to_string()];
+ let result = apply_partial_update(resolved, &old_lock, &update_packages);
+
+ let psr = result.iter().find(|p| p.name == "psr/log").unwrap();
+ assert_eq!(psr.version, "3.0.0");
+ }
+
+ #[test]
+ fn test_apply_partial_update_new_package_in_update_list() {
+ // A brand new package resolved that is in the update list should use the new version
+ let old_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+ let resolved = vec![
+ make_resolved_package("psr/log", "3.0.0"),
+ make_resolved_package("new/package", "1.0.0"),
+ ];
+
+ let update_packages = vec!["new/package".to_string()];
+ let result = apply_partial_update(resolved, &old_lock, &update_packages);
+
+ let new_pkg = result.iter().find(|p| p.name == "new/package").unwrap();
+ assert_eq!(new_pkg.version, "1.0.0");
+ }
+
+ #[test]
+ fn test_apply_partial_update_full_update_mode() {
+ // If update_packages is empty, it should behave like full update (no swapping)
+ let old_lock = minimal_lock(vec![make_locked_package("psr/log", "3.0.0")]);
+ let resolved = vec![make_resolved_package("psr/log", "3.0.1")];
+
+ // Empty update list means... everything is not in the update set,
+ // so old versions are preserved. This is the expected behavior for partial mode
+ // when packages is empty (which shouldn't happen - full update is separate path).
+ let update_packages: Vec<String> = vec![];
+ let result = apply_partial_update(resolved, &old_lock, &update_packages);
+
+ // When update_packages is empty, nothing is in the update set, so old versions revert
+ let psr = result.iter().find(|p| p.name == "psr/log").unwrap();
+ assert_eq!(psr.version, "3.0.0");
+ }
+
+ // ──────────── lock mode helpers ────────────
+
+ #[test]
+ fn test_handle_lock_mode_updates_hash() {
+ let dir = tempfile::tempdir().unwrap();
+ let lock_path = dir.path().join("composer.lock");
+
+ // Write an existing lock with a known hash
+ let mut lock = minimal_lock(vec![]);
+ lock.content_hash = "old_hash_value".to_string();
+ lock.write_to_file(&lock_path).unwrap();
+
+ // Composer.json content that will produce a different hash
+ let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#;
+
+ let result = handle_lock_mode(&lock_path, composer_json_content, false);
+ assert!(result.is_ok());
+
+ // Read back and verify hash changed
+ let updated_lock = lockfile::LockFile::read_from_file(&lock_path).unwrap();
+ assert_ne!(updated_lock.content_hash, "old_hash_value");
+ let expected_hash =
+ lockfile::LockFile::compute_content_hash(composer_json_content).unwrap();
+ assert_eq!(updated_lock.content_hash, expected_hash);
+ }
+
+ #[test]
+ fn test_handle_lock_mode_no_change_when_hash_matches() {
+ let dir = tempfile::tempdir().unwrap();
+ let lock_path = dir.path().join("composer.lock");
+
+ let composer_json_content = r#"{"name": "test/project", "require": {}}"#;
+ let correct_hash = lockfile::LockFile::compute_content_hash(composer_json_content).unwrap();
+
+ let mut lock = minimal_lock(vec![]);
+ lock.content_hash = correct_hash.clone();
+ lock.write_to_file(&lock_path).unwrap();
+
+ let result = handle_lock_mode(&lock_path, composer_json_content, false);
+ assert!(result.is_ok());
+
+ // Hash should not have changed
+ let reloaded = lockfile::LockFile::read_from_file(&lock_path).unwrap();
+ assert_eq!(reloaded.content_hash, correct_hash);
+ }
+
+ #[test]
+ fn test_handle_lock_mode_dry_run_does_not_write() {
+ let dir = tempfile::tempdir().unwrap();
+ let lock_path = dir.path().join("composer.lock");
+
+ let mut lock = minimal_lock(vec![]);
+ lock.content_hash = "original_hash".to_string();
+ lock.write_to_file(&lock_path).unwrap();
+
+ let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#;
+
+ let result = handle_lock_mode(&lock_path, composer_json_content, true);
+ assert!(result.is_ok());
+
+ // Hash should NOT have changed (dry_run=true)
+ let reloaded = lockfile::LockFile::read_from_file(&lock_path).unwrap();
+ assert_eq!(reloaded.content_hash, "original_hash");
+ }
+
+ // ──────────── Integration test (network, #[ignore]) ────────────
+
+ #[test]
+ #[ignore]
+ fn test_update_full_e2e() {
+ use crate::lockfile::{LockFileGenerationRequest, generate_lock_file};
+ use crate::package::RawPackageData;
+ use crate::resolver::{ResolveRequest, resolve};
+
+ let composer_json_content =
+ r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#;
+ let composer_json: RawPackageData = serde_json::from_str(composer_json_content).unwrap();
+
+ let request = ResolveRequest {
+ require: vec![("monolog/monolog".to_string(), "^3.0".to_string())],
+ require_dev: vec![],
+ include_dev: false,
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: true,
+ prefer_lowest: false,
+ platform: PlatformConfig::new(),
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ };
+
+ let resolved = resolve(&request).expect("Resolution should succeed");
+ assert!(!resolved.is_empty());
+ assert!(resolved.iter().any(|p| p.name == "monolog/monolog"));
+
+ let lock = generate_lock_file(&LockFileGenerationRequest {
+ resolved_packages: resolved,
+ composer_json_content: composer_json_content.to_string(),
+ composer_json,
+ include_dev: false,
+ })
+ .expect("Lock file generation should succeed");
+
+ assert!(!lock.content_hash.is_empty());
+ assert!(!lock.packages.is_empty());
+ assert!(lock.packages.iter().any(|p| p.name == "monolog/monolog"));
+ }
+
+ #[test]
+ #[ignore]
+ fn test_update_lock_only_e2e() {
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let lock_path = dir.path().join("composer.lock");
+
+ // Write a lock with an outdated hash
+ let mut lock = minimal_lock(vec![]);
+ lock.content_hash = "outdated_hash".to_string();
+ lock.write_to_file(&lock_path).unwrap();
+
+ let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#;
+ let expected_hash =
+ lockfile::LockFile::compute_content_hash(composer_json_content).unwrap();
+
+ handle_lock_mode(&lock_path, composer_json_content, false).unwrap();
+
+ let updated = lockfile::LockFile::read_from_file(&lock_path).unwrap();
+ assert_eq!(updated.content_hash, expected_hash);
+ // The packages should be unchanged (lock mode doesn't resolve)
+ assert!(updated.packages.is_empty());
+ }
}
diff --git a/crates/mozart/src/package.rs b/crates/mozart/src/package.rs
index e0e8c6c..e439ac5 100644
--- a/crates/mozart/src/package.rs
+++ b/crates/mozart/src/package.rs
@@ -17,6 +17,22 @@ pub enum Stability {
Dev = 20,
}
+impl Stability {
+ /// Parse a stability string (case-insensitive) into a `Stability` value.
+ ///
+ /// Recognizes: "stable", "RC", "beta", "alpha", "dev".
+ /// Defaults to `Stability::Stable` for unrecognized values.
+ pub fn parse(s: &str) -> Self {
+ match s.to_lowercase().as_str() {
+ "dev" => Stability::Dev,
+ "alpha" => Stability::Alpha,
+ "beta" => Stability::Beta,
+ "rc" => Stability::RC,
+ _ => Stability::Stable,
+ }
+ }
+}
+
/// A versioned relationship between two packages.
/// Corresponds to `Composer\Package\Link`.
#[derive(Debug, Clone)]