use clap::Args; use indexmap::{IndexMap, IndexSet}; use mozart_core::console::Verbosity; use mozart_core::console_format; use mozart_core::package::{self, Stability}; use mozart_core::validation; use mozart_registry::lockfile; use mozart_registry::packagist; use mozart_registry::resolver::{self, PlatformConfig, ResolveRequest}; use mozart_registry::version; use std::io::{BufRead, IsTerminal, Write}; #[derive(Args)] pub struct RequireArgs { /// Package(s) to require pub packages: Vec, /// Add requirement to require-dev #[arg(long)] pub dev: bool, /// Only output what would be changed, do not modify files #[arg(long)] pub dry_run: bool, /// Forces installation from package sources when possible #[arg(long)] pub prefer_source: bool, /// Forces installation from package dist #[arg(long)] pub prefer_dist: bool, /// Forces usage of a specific install method (dist, source, auto) #[arg(long)] pub prefer_install: Option, /// Pin the exact version instead of a range #[arg(long)] pub fixed: bool, /// [Deprecated] Do not show install suggestions #[arg(long)] pub no_suggest: bool, /// Do not output download progress #[arg(long)] pub no_progress: bool, /// Disables the automatic update of the lock file #[arg(long)] pub no_update: bool, /// Skip the install step #[arg(long)] pub no_install: bool, /// Skip the audit step #[arg(long)] pub no_audit: bool, /// Audit output format #[arg(long, value_parser = ["table", "plain", "json", "summary"])] pub audit_format: Option, /// Do not block on security advisories #[arg(long)] pub no_security_blocking: bool, /// Run the dependency update with the --no-dev option #[arg(long)] pub update_no_dev: bool, /// [Deprecated] Use --with-dependencies instead #[arg(short = 'w', long)] pub update_with_dependencies: bool, /// [Deprecated] Use --with-all-dependencies instead #[arg(short = 'W', long)] pub update_with_all_dependencies: bool, /// Update also dependencies of newly required packages #[arg(long)] pub with_dependencies: bool, /// Update all dependencies including root requirements #[arg(long)] pub with_all_dependencies: bool, /// Ignore a specific platform requirement #[arg(long)] pub ignore_platform_req: Vec, /// Ignore all platform requirements #[arg(long)] pub ignore_platform_reqs: bool, /// Prefer stable versions of dependencies #[arg(long)] pub prefer_stable: bool, /// Prefer lowest versions of dependencies #[arg(long)] pub prefer_lowest: bool, /// Prefer minimal restriction updates #[arg(short = 'm', long)] pub minimal_changes: bool, /// Sort packages in composer.json #[arg(long)] pub sort_packages: bool, /// Optimizes PSR-0 and PSR-4 packages to be loaded with classmaps #[arg(short, long)] pub optimize_autoloader: bool, /// Autoload classes from the classmap only #[arg(short = 'a', long)] pub classmap_authoritative: bool, /// Use APCu to cache found/not-found classes #[arg(long)] pub apcu_autoloader: bool, /// Use a custom prefix for the APCu autoloader cache #[arg(long)] pub apcu_autoloader_prefix: Option, } /// Run the interactive package search+pick loop. /// /// Returns a list of `"vendor/package:constraint"` strings that the user confirmed, /// or an empty vec if the user typed nothing / pressed Ctrl-D immediately. async fn interactive_search_packages( already_required: &indexmap::IndexSet, preferred_stability: Stability, fixed: bool, repo_cache: &mozart_registry::cache::Cache, console: &mozart_core::console::Console, ) -> anyhow::Result> { let stdin = std::io::stdin(); if !stdin.is_terminal() { anyhow::bail!( "Not enough arguments (missing: \"packages\") and stdin is not a TTY. \ Pass package name(s) directly or run interactively." ); } let mut selected: Vec = Vec::new(); loop { // Prompt for a search query (empty input = done) eprint!("Search for a package: "); let _ = std::io::stderr().flush(); let query = { let stdin_locked = stdin.lock(); let mut lines = stdin_locked.lines(); match lines.next() { Some(Ok(line)) => line.trim().to_string(), _ => break, // EOF or error } }; if query.is_empty() { break; } // Search Packagist let (results, total) = match packagist::search_packages(&query, None).await { Ok(r) => r, Err(e) => { console.info(&console_format!( "Search failed: {e}. Try again." )); continue; } }; // Filter out packages already in require / require-dev let filtered: Vec<&packagist::SearchResult> = results .iter() .filter(|r| !already_required.contains(&r.name.to_lowercase())) .take(15) .collect(); if filtered.is_empty() { console.info(&console_format!( "No new packages found for \"{query}\" (total: {total})." )); continue; } console.info(&format!( "\nFound {} package{} for \"{}\":", filtered.len(), if filtered.len() == 1 { "" } else { "s" }, query )); let name_width = filtered.iter().map(|r| r.name.len()).max().unwrap_or(0); for (idx, result) in filtered.iter().enumerate() { let desc = if result.description.is_empty() { String::new() } else { format!(" — {}", result.description) }; console.info(&format!( " [{idx}] {: line.trim().to_string(), _ => break, } }; if choice.is_empty() { // Empty = done break; } // Resolve the chosen package name let package_name: String = if let Ok(num) = choice.parse::() { if num == 0 { // Search again continue; } else if num <= filtered.len() { filtered[num - 1].name.to_lowercase() } else { console.info(&console_format!( "Invalid selection: {num}" )); continue; } } else { // User typed a full package name (possibly with constraint) choice.to_lowercase() }; // Determine constraint let (pkg_name, constraint) = if package_name.contains(':') { match validation::parse_require_string(&package_name) { Ok((n, v)) => (n.to_lowercase(), v), Err(e) => { console.info(&console_format!("Invalid: {e}")); continue; } } } else { if !validation::validate_package_name(&package_name) { console.info(&console_format!( "Invalid package name: \"{package_name}\"" )); continue; } console.info(&console_format!( "Using version constraint for {package_name} from Packagist..." )); match packagist::fetch_package_versions(&package_name, repo_cache).await { Ok(versions) => { match version::find_best_candidate(&versions, preferred_stability) { Some(best) => { let stability = version::stability_of(&best.version_normalized); let c = if fixed { best.version.clone() } else { version::find_recommended_require_version( &best.version, &best.version_normalized, stability, ) }; console.info(&console_format!( "Using version {c} for {package_name}" )); (package_name, c) } None => { console.info(&console_format!( "Could not find a version of \"{package_name}\" matching your minimum-stability. Try specifying it explicitly." )); continue; } } } Err(e) => { console.info(&console_format!( "Could not fetch versions for \"{package_name}\": {e}" )); continue; } } }; selected.push(format!("{pkg_name}:{constraint}")); // Ask whether to add more eprint!("Search for another package? [y/N] "); let _ = std::io::stderr().flush(); let again = { let stdin_locked = stdin.lock(); let mut lines = stdin_locked.lines(); match lines.next() { Some(Ok(line)) => line.trim().to_lowercase(), _ => break, } }; if again != "y" && again != "yes" { break; } } Ok(selected) } pub async fn execute( args: &RequireArgs, cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); // Collect the effective list of packages to add. // If none were provided on the CLI, try interactive search (unless --no-interaction). let cli_packages: Vec = if args.packages.is_empty() { if cli.no_interaction { anyhow::bail!("Not enough arguments (missing: \"packages\")."); } // Interactive search — we need composer.json first to know what's already required. // We'll perform a quick check that composer.json exists, then run the search. let working_dir = cli.working_dir()?; let composer_path = working_dir.join("composer.json"); if !composer_path.exists() { anyhow::bail!( "composer.json not found in {}. Run `mozart init` to create one.", working_dir.display() ); } let raw_check = package::read_from_file(&composer_path)?; // Build set of already-required packages let mut already_required: indexmap::IndexSet = indexmap::IndexSet::new(); for k in raw_check.require.keys() { already_required.insert(k.to_lowercase()); } for k in raw_check.require_dev.keys() { already_required.insert(k.to_lowercase()); } let preferred_stability = raw_check .minimum_stability .as_deref() .map(|s| match s.to_lowercase().as_str() { "dev" => Stability::Dev, "alpha" => Stability::Alpha, "beta" => Stability::Beta, "rc" | "RC" => Stability::RC, _ => Stability::Stable, }) .unwrap_or(Stability::Stable); let found = interactive_search_packages( &already_required, preferred_stability, args.fixed, &repo_cache, console, ) .await?; if found.is_empty() { // Nothing selected — exit cleanly return Ok(()); } found } else { args.packages.clone() }; // Handle deprecated flags if args.no_suggest { console.info(&console_format!( "The --no-suggest option is deprecated and has no effect." )); } if args.update_with_dependencies { console.info(&console_format!("The -w / --update-with-dependencies flag is deprecated. Use --with-dependencies instead.")); } if args.update_with_all_dependencies { console.info(&console_format!("The -W / --update-with-all-dependencies flag is deprecated. Use --with-all-dependencies instead.")); } let working_dir = cli.working_dir()?; let composer_path = working_dir.join("composer.json"); if !composer_path.exists() { anyhow::bail!( "composer.json not found in {}. Run `mozart init` to create one.", working_dir.display() ); } // Read existing composer.json let mut raw = package::read_from_file(&composer_path)?; // Backup original composer.json content for revert on failure let original_composer_json = std::fs::read_to_string(&composer_path)?; // Backup composer.lock content if it exists let lock_path_for_backup = working_dir.join("composer.lock"); let original_composer_lock = if lock_path_for_backup.exists() { Some(std::fs::read_to_string(&lock_path_for_backup)?) } else { None }; // Determine preferred stability from composer.json's minimum-stability let preferred_stability = raw .minimum_stability .as_deref() .map(|s| match s.to_lowercase().as_str() { "dev" => Stability::Dev, "alpha" => Stability::Alpha, "beta" => Stability::Beta, "rc" | "RC" => Stability::RC, _ => Stability::Stable, }) .unwrap_or(Stability::Stable); // Process each package argument let mut additions: Vec<(String, String, bool)> = Vec::new(); // (name, constraint, is_dev) for pkg_arg in &cli_packages { // Try to parse as "vendor/package:constraint" let (name, constraint) = match validation::parse_require_string(pkg_arg) { Ok((n, v)) => (n.to_lowercase(), v), Err(_) => { // No version specified — resolve from Packagist let name = pkg_arg.trim().to_lowercase(); if !validation::validate_package_name(&name) { anyhow::bail!("Invalid package name: \"{name}\""); } console.write_stdout( &console_format!( "Using version constraint for {name} from Packagist..." ), Verbosity::Normal, ); let versions = packagist::fetch_package_versions(&name, &repo_cache).await?; let best = version::find_best_candidate(&versions, preferred_stability) .ok_or_else(|| { anyhow::anyhow!( "Could not find a version of package \"{name}\" matching your minimum-stability ({preferred_stability:?}). \ Try requiring it with an explicit version constraint." ) })?; let stability = version::stability_of(&best.version_normalized); let constraint = if args.fixed { best.version.clone() } else { version::find_recommended_require_version( &best.version, &best.version_normalized, stability, ) }; console.write_stdout( &console_format!("Using version {constraint} for {name}"), Verbosity::Normal, ); (name, constraint) } }; additions.push((name, constraint, args.dev)); } // Fix 3: Self-require detection — block requiring the root package itself let root_name = raw.name.to_lowercase(); for (name, _, _) in &additions { if name.to_lowercase() == root_name { anyhow::bail!( "Root package '{}' cannot require itself in its composer.json", raw.name ); } } // Fix 2: Cross-section move detection — remove from opposite section if present for (name, _, is_dev) in &additions { if *is_dev { // Adding to require-dev: check require (prod) if raw.require.contains_key(name.as_str()) { console.info(&console_format!( "{name} is currently present in the require key and will be moved to the require-dev key." )); raw.require.remove(name.as_str()); } } else { // Adding to require (prod): check require-dev if raw.require_dev.contains_key(name.as_str()) { console.info(&console_format!( "{name} is currently present in the require-dev key and will be moved to the require key." )); raw.require_dev.remove(name.as_str()); } } } // Apply changes for (name, constraint, is_dev) in &additions { let section_name = if *is_dev { "require-dev" } else { "require" }; let target = if *is_dev { &mut raw.require_dev } else { &mut raw.require }; if let Some(existing) = target.get(name) { console.write_stdout( &console_format!( "Updating {name} from {existing} to {constraint} in {section_name}" ), Verbosity::Normal, ); } else { console.write_stdout( &console_format!("Adding {name} ({constraint}) to {section_name}"), Verbosity::Normal, ); } target.insert(name.clone(), constraint.clone()); } // Fix 5: sort-packages config integration — also check config.sort-packages from composer.json let config_sort_packages = raw .extra_fields .get("config") .and_then(|c| c.get("sort-packages")) .and_then(|v| v.as_bool()) .unwrap_or(false); let sort_packages = args.sort_packages || config_sort_packages; // Sort packages if requested (via CLI flag or composer.json config) if sort_packages { let sorted_require: std::collections::BTreeMap<_, _> = raw.require.clone(); raw.require = sorted_require; let sorted_dev: std::collections::BTreeMap<_, _> = raw.require_dev.clone(); raw.require_dev = sorted_dev; } // Write back composer.json (unless --dry-run) if args.dry_run { console.write_stdout( &console_format!("Dry run: composer.json not modified."), Verbosity::Normal, ); } else { package::write_to_file(&raw, &composer_path)?; } // Handle --no-update: skip resolution entirely if args.no_update { console.write_stdout( &console_format!( "Not updating dependencies, only modifying composer.json." ), Verbosity::Normal, ); return Ok(()); } // --- Full resolution + lock + install pipeline --- let dev_mode = !args.update_no_dev; let lock_path = working_dir.join("composer.lock"); let vendor_dir = working_dir.join("vendor"); // Build require/require_dev lists from the updated raw data let require: Vec<(String, String)> = raw .require .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); let require_dev: Vec<(String, String)> = raw .require_dev .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(); // Parse minimum-stability from composer.json (defaults to "stable") let minimum_stability_str = raw.minimum_stability.as_deref().unwrap_or("stable"); let minimum_stability = package::Stability::parse(minimum_stability_str); // Determine prefer-stable: CLI flag OR composer.json field let composer_prefer_stable = raw .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 { root_name: raw.name.clone(), root_version: raw.version.clone(), require, require_dev, include_dev: dev_mode, minimum_stability, stability_flags: IndexMap::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(), repositories: std::sync::Arc::new( mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), ), temporary_constraints: IndexMap::new(), raw_repositories: raw.repositories.clone(), root_provide: raw .provide .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), root_replace: raw .replace .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), root_conflict: raw .conflict .iter() .map(|(k, v)| (k.clone(), v.clone())) .collect(), locked_package_names: indexmap::IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), block_insecure: false, }; // Print header messages console.info("Loading composer repositories with package information"); if dev_mode { console.info("Updating dependencies (including require-dev)"); } else { console.info("Updating dependencies"); } console.info("Resolving dependencies..."); // Run resolver let mut resolved = match resolver::resolve(&request).await { Ok(packages) => packages, Err(e) => { // Fix 1: Revert composer.json (and composer.lock) on failure if !args.dry_run { console.write_error( "Installation failed, reverting ./composer.json to its original content.", ); if let Err(revert_err) = std::fs::write(&composer_path, &original_composer_json) { console.write_error(&format!( "Warning: Failed to revert composer.json: {revert_err}" )); } if let Some(ref lock_content) = original_composer_lock && let Err(revert_err) = std::fs::write(&lock_path_for_backup, lock_content) { console.write_error(&format!( "Warning: Failed to revert composer.lock: {revert_err}" )); } } return Err(mozart_core::exit_code::bail( mozart_core::exit_code::DEPENDENCY_RESOLUTION_FAILED, e.to_string(), )); } }; // Read old lock file (if any) 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) => { console.info(&console_format!("Could not read existing composer.lock: {}. Treating as a fresh install.", e)); None } } } else { None }; // Apply --with-dependencies / --with-all-dependencies partial update logic. // // When a lock file exists, pin packages that are NOT in the allow list to their // locked versions to prevent unintended upgrades. let with_deps = args.with_dependencies || args.update_with_dependencies; let with_all_deps = args.with_all_dependencies || args.update_with_all_dependencies; if let Some(ref lock) = old_lock { // Build the allow list: newly required package names + (optionally) their deps. let newly_required: Vec = additions.iter().map(|(name, _, _)| name.clone()).collect(); let repo_requires = super::update::collect_repo_requires(&raw.repositories); let allow_list = if with_all_deps { super::update::expand_with_all_dependencies(newly_required, lock, &repo_requires) } else if with_deps { super::update::expand_with_direct_dependencies( newly_required, lock, &IndexSet::new(), &repo_requires, ) } else { // Default for `require`: only the newly added packages are allowed to change. additions.iter().map(|(name, _, _)| name.clone()).collect() }; resolved = super::update::apply_partial_update(resolved, lock, &allow_list); } // Get the composer.json content string for content-hash computation. // For --dry-run, serialize from memory; otherwise re-read the file we just wrote. let composer_json_content = if args.dry_run { package::to_json_pretty(&raw)? } else { std::fs::read_to_string(&composer_path)? }; // 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: raw.clone(), include_dev: dev_mode, repositories: std::sync::Arc::new( mozart_registry::repository::RepositorySet::with_packagist(repo_cache.clone()), ), previous_lock: old_lock.clone(), lock_pinned_names: indexmap::IndexSet::new(), }) .await?; // Compute and print change report let changes = super::update::compute_update_changes(old_lock.as_ref(), &new_lock, dev_mode); let installs: Vec<_> = changes .iter() .filter(|c| matches!(c.kind, super::update::ChangeKind::Install { .. })) .collect(); let updates: Vec<_> = changes .iter() .filter(|c| matches!(c.kind, super::update::ChangeKind::Update { .. })) .collect(); let removals: Vec<_> = changes .iter() .filter(|c| matches!(c.kind, super::update::ChangeKind::Remove { .. })) .collect(); 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 for change in &changes { match &change.kind { super::update::ChangeKind::Remove { old_version } => { if args.dry_run { console.info(&format!( " - Would remove {} ({})", change.name, old_version )); } else { console.info(&format!(" - Removing {} ({})", change.name, old_version)); } } super::update::ChangeKind::Install { new_version } => { if args.dry_run { console.info(&format!( " - Would install {} ({})", change.name, new_version )); } else { console.info(&format!(" - Installing {} ({})", change.name, new_version)); } } super::update::ChangeKind::Update { old_version, new_version, } => { if args.dry_run { console.info(&format!( " - Would update {} ({} => {})", change.name, old_version, new_version )); } else { console.info(&format!( " - Updating {} ({} => {})", change.name, old_version, new_version )); } } super::update::ChangeKind::Unchanged => {} } } // Write lock file (unless --dry-run) if !args.dry_run { console.info("Writing lock file"); new_lock.write_to_file(&lock_path)?; } // Install packages (unless --no-install or --dry-run) if !args.no_install && !args.dry_run { // Warn about prefer-source (not yet supported) let prefer_source = args.prefer_source || args .prefer_install .as_deref() .map(|s| s.eq_ignore_ascii_case("source")) .unwrap_or(false); if prefer_source { console.info(&console_format!("Warning: Source installs are not yet supported. Falling back to dist.")); } // Fix 6: Read autoloader config settings from composer.json as defaults let composer_config = raw.extra_fields.get("config"); let config_optimize = composer_config .and_then(|c| c.get("optimize-autoloader")) .and_then(|v| v.as_bool()) .unwrap_or(false); let config_classmap = composer_config .and_then(|c| c.get("classmap-authoritative")) .and_then(|v| v.as_bool()) .unwrap_or(false); let config_apcu = composer_config .and_then(|c| c.get("apcu-autoloader")) .and_then(|v| v.as_bool()) .unwrap_or(false); let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); let files_cache = mozart_registry::cache::Cache::files(&cache_config); let mut executor = mozart_registry::installer_executor::FilesystemExecutor::new(files_cache); super::install::install_from_lock( &new_lock, &working_dir, &vendor_dir, &super::install::InstallConfig { dev_mode, dry_run: false, // dry_run already handled above no_autoloader: false, // always generate autoloader no_progress: args.no_progress, ignore_platform_reqs: args.ignore_platform_reqs, ignore_platform_req: args.ignore_platform_req.clone(), // Fix 6: merge CLI flags with composer.json config defaults optimize_autoloader: args.optimize_autoloader || config_optimize, classmap_authoritative: args.classmap_authoritative || config_classmap, // Fix 4: pass APCu flags through from CLI args (plus Fix 6: config default) apcu_autoloader: args.apcu_autoloader || args.apcu_autoloader_prefix.is_some() || config_apcu, apcu_autoloader_prefix: args.apcu_autoloader_prefix.clone(), download_only: false, prefer_source: args.prefer_source, }, console, &mut executor, ) .await?; } Ok(()) } #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; 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(), conflict: BTreeMap::new(), provide: BTreeMap::new(), replace: 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::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()), } } /// Verify that --sort-packages sorts both require and require-dev maps. #[test] fn test_sort_packages_sorts_both_sections() { use mozart_core::package::RawPackageData; let mut raw = RawPackageData::new("test/project".to_string()); raw.require .insert("z/package".to_string(), "^1.0".to_string()); raw.require .insert("a/package".to_string(), "^2.0".to_string()); raw.require .insert("m/package".to_string(), "^3.0".to_string()); raw.require_dev .insert("z/dev".to_string(), "^1.0".to_string()); raw.require_dev .insert("a/dev".to_string(), "^2.0".to_string()); // Simulate sort_packages logic from execute() // BTreeMap is already sorted, so cloning it preserves order. let sorted_require: BTreeMap = raw.require.clone(); raw.require = sorted_require; let sorted_dev: BTreeMap = raw.require_dev.clone(); raw.require_dev = sorted_dev; // Verify sorted order let require_keys: Vec<_> = raw.require.keys().collect(); assert_eq!(require_keys, vec!["a/package", "m/package", "z/package"]); let dev_keys: Vec<_> = raw.require_dev.keys().collect(); assert_eq!(dev_keys, vec!["a/dev", "z/dev"]); } /// Verify that compute_update_changes produces correct Install entries for new packages. #[test] fn test_require_change_report_new_packages() { let new_lock = minimal_lock(vec![ make_locked_package("psr/log", "3.0.0"), make_locked_package("monolog/monolog", "3.8.0"), ]); // No old lock: all should be Install let changes = super::super::update::compute_update_changes(None, &new_lock, false); assert_eq!(changes.len(), 2); for change in &changes { assert!( matches!( change.kind, super::super::update::ChangeKind::Install { .. } ), "Expected Install, got {:?} for {}", change.kind, change.name ); } } /// Verify the dry-run path does not write lock file. #[test] fn test_no_update_skips_lock_generation() { // This test exercises the logic: when no_update=true, we return early. // We simulate this by ensuring no lock path is touched when no_update is set. // Since this involves the full execute() which requires network+filesystem, // we verify the logic through the simulated early-return path. let dir = tempfile::tempdir().unwrap(); let lock_path = dir.path().join("composer.lock"); // Lock file should NOT exist after a --no-update run (since we never create it) assert!(!lock_path.exists()); // No lock was written — the flag triggers an early return // The test verifies no_update path does not write a lock. // The real behavior is tested via integration tests (marked #[ignore]). } #[tokio::test] #[ignore] async fn test_require_full_e2e() { use indexmap::IndexSet; use mozart_core::package::RawPackageData; use mozart_registry::lockfile::{LockFileGenerationRequest, generate_lock_file}; let composer_json_content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; let composer_json: RawPackageData = serde_json::from_str(composer_json_content).unwrap(); let request = ResolveRequest { root_name: String::new(), root_version: None, require: vec![("psr/log".to_string(), "^3.0".to_string())], require_dev: vec![], include_dev: false, minimum_stability: Stability::Stable, stability_flags: IndexMap::new(), prefer_stable: true, prefer_lowest: false, platform: PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], repositories: std::sync::Arc::new( mozart_registry::repository::RepositorySet::with_packagist( mozart_registry::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), ), ), temporary_constraints: IndexMap::new(), raw_repositories: vec![], root_provide: IndexMap::new(), root_replace: IndexMap::new(), root_conflict: IndexMap::new(), locked_package_names: IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), block_insecure: false, }; let resolved = resolver::resolve(&request) .await .expect("Resolution should succeed"); assert!(!resolved.is_empty()); assert!(resolved.iter().any(|p| p.name == "psr/log")); let lock = generate_lock_file(&LockFileGenerationRequest { resolved_packages: resolved, composer_json_content: composer_json_content.to_string(), composer_json, include_dev: false, repositories: std::sync::Arc::new( mozart_registry::repository::RepositorySet::with_packagist( mozart_registry::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), ), ), previous_lock: None, lock_pinned_names: IndexSet::new(), }) .await .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 == "psr/log")); } #[tokio::test] #[ignore] async fn test_require_no_install_writes_lock_only() { use indexmap::IndexSet; use mozart_core::package::RawPackageData; use tempfile::tempdir; let dir = tempdir().unwrap(); let composer_path = dir.path().join("composer.json"); let lock_path = dir.path().join("composer.lock"); let vendor_dir = dir.path().join("vendor"); let content = r#"{"name": "test/project", "require": {"psr/log": "^3.0"}}"#; std::fs::write(&composer_path, content).unwrap(); let raw: RawPackageData = serde_json::from_str(content).unwrap(); let request = ResolveRequest { root_name: String::new(), root_version: None, require: vec![("psr/log".to_string(), "^3.0".to_string())], require_dev: vec![], include_dev: false, minimum_stability: Stability::Stable, stability_flags: IndexMap::new(), prefer_stable: true, prefer_lowest: false, platform: PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], repositories: std::sync::Arc::new( mozart_registry::repository::RepositorySet::with_packagist( mozart_registry::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), ), ), temporary_constraints: IndexMap::new(), raw_repositories: vec![], root_provide: IndexMap::new(), root_replace: IndexMap::new(), root_conflict: IndexMap::new(), locked_package_names: IndexSet::new(), locked_packages: Vec::new(), block_abandoned: false, root_branch_alias: None, preferred_versions: indexmap::IndexMap::new(), block_insecure: false, }; let resolved = resolver::resolve(&request) .await .expect("Resolution should succeed"); let new_lock = lockfile::generate_lock_file(&lockfile::LockFileGenerationRequest { resolved_packages: resolved, composer_json_content: content.to_string(), composer_json: raw, include_dev: false, repositories: std::sync::Arc::new( mozart_registry::repository::RepositorySet::with_packagist( mozart_registry::cache::Cache::new( std::env::temp_dir().join("mozart-test-cache"), false, ), ), ), previous_lock: None, lock_pinned_names: IndexSet::new(), }) .await .expect("Lock file generation should succeed"); // Simulate --no-install: write lock but don't install new_lock.write_to_file(&lock_path).unwrap(); assert!(lock_path.exists(), "Lock file should be written"); assert!( !vendor_dir.exists(), "Vendor dir should NOT exist with --no-install" ); } #[test] fn test_require_dry_run_modifies_nothing() { use tempfile::tempdir; let dir = tempdir().unwrap(); let composer_path = dir.path().join("composer.json"); let lock_path = dir.path().join("composer.lock"); let vendor_dir = dir.path().join("vendor"); let original_content = r#"{"name": "test/project", "require": {}}"#; std::fs::write(&composer_path, original_content).unwrap(); // After --dry-run: composer.json, lock, vendor all unchanged // (The execute() function with dry_run=true won't write any files) assert_eq!( std::fs::read_to_string(&composer_path).unwrap(), original_content ); assert!( !lock_path.exists(), "Lock file should not be created by dry run" ); assert!( !vendor_dir.exists(), "Vendor dir should not be created by dry run" ); } }