diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 15:19:18 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 15:19:18 +0900 |
| commit | c04744719bd16d9414a9f9a358691d03a993670c (patch) | |
| tree | 2354da304c97094bfbe23bb38ceba08d60ea19a2 /crates/mozart/src/commands/require.rs | |
| parent | c07e2073e0484924f80f3bb68ea95ce127b42df6 (diff) | |
| download | php-mozart-c04744719bd16d9414a9f9a358691d03a993670c.tar.gz php-mozart-c04744719bd16d9414a9f9a358691d03a993670c.tar.zst php-mozart-c04744719bd16d9414a9f9a358691d03a993670c.zip | |
feat(require,remove): add interactive search and dependency-aware partial updates
Implement Phase 5.5 of the require/remove commands:
- Interactive package search when no packages specified on CLI (require)
- --with-dependencies/--with-all-dependencies partial update for require
- --with-all-dependencies/--no-update-with-dependencies for remove
- --minimal-changes support for remove
- Extract search API types and logic from search.rs into packagist.rs
for reuse by both search and require commands
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src/commands/require.rs')
| -rw-r--r-- | crates/mozart/src/commands/require.rs | 320 |
1 files changed, 296 insertions, 24 deletions
diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs index b9ec258..b1062f0 100644 --- a/crates/mozart/src/commands/require.rs +++ b/crates/mozart/src/commands/require.rs @@ -7,6 +7,7 @@ use crate::validation; use crate::version; use clap::Args; use std::collections::HashMap; +use std::io::{BufRead, IsTerminal, Write}; #[derive(Args)] pub struct RequireArgs { @@ -126,11 +127,276 @@ pub struct RequireArgs { pub apcu_autoloader_prefix: Option<String>, } -pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { - if args.packages.is_empty() { - anyhow::bail!("Not enough arguments (missing: \"packages\")."); +/// 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. +fn interactive_search_packages( + already_required: &std::collections::HashSet<String>, + preferred_stability: Stability, + fixed: bool, +) -> anyhow::Result<Vec<String>> { + 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<String> = 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) { + Ok(r) => r, + Err(e) => { + eprintln!( + "{}", + console::warning(&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() { + eprintln!( + "{}", + console::warning(&format!( + "No new packages found for \"{query}\" (total: {total})." + )) + ); + continue; + } + + eprintln!( + "\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) + }; + eprintln!( + " [{idx}] {:<width$}{desc}", + result.name, + idx = idx + 1, + width = name_width, + ); + } + eprintln!(" [0] Search again / enter full package name"); + eprintln!(); + + // Ask user to pick + eprint!("Enter package # or name (leave empty to finish): "); + let _ = std::io::stderr().flush(); + + let choice = { + let stdin_locked = stdin.lock(); + let mut lines = stdin_locked.lines(); + match lines.next() { + Some(Ok(line)) => 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::<usize>() { + if num == 0 { + // Search again + continue; + } else if num <= filtered.len() { + filtered[num - 1].name.to_lowercase() + } else { + eprintln!("{}", console::warning(&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) => { + eprintln!("{}", console::warning(&format!("Invalid: {e}"))); + continue; + } + } + } else { + if !validation::validate_package_name(&package_name) { + eprintln!( + "{}", + console::warning(&format!("Invalid package name: \"{package_name}\"")) + ); + continue; + } + + eprintln!( + "{}", + console::info(&format!( + "Using version constraint for {package_name} from Packagist..." + )) + ); + + match packagist::fetch_package_versions(&package_name) { + 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, + ) + }; + eprintln!( + "{}", + console::info(&format!("Using version {c} for {package_name}")) + ); + (package_name, c) + } + None => { + eprintln!( + "{}", + console::warning(&format!( + "Could not find a version of \"{package_name}\" matching \ + your minimum-stability. Try specifying it explicitly." + )) + ); + continue; + } + } + } + Err(e) => { + eprintln!( + "{}", + console::warning(&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 fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { + // 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<String> = 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 = super::install::resolve_working_dir(cli); + 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: std::collections::HashSet<String> = + std::collections::HashSet::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)?; + + if found.is_empty() { + // Nothing selected — exit cleanly + return Ok(()); + } + + found + } else { + args.packages.clone() + }; + // Handle deprecated flags if args.no_suggest { eprintln!( @@ -155,24 +421,6 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { ); } - // Warn about flags that are accepted but not fully implemented - if args.with_dependencies || args.update_with_dependencies { - eprintln!( - "{}", - console::warning( - "--with-dependencies is not yet implemented; full resolution is always performed." - ) - ); - } - if args.with_all_dependencies || args.update_with_all_dependencies { - eprintln!( - "{}", - console::warning( - "--with-all-dependencies is not yet implemented; full resolution is always performed." - ) - ); - } - // Resolve working directory let working_dir = super::install::resolve_working_dir(cli); @@ -203,7 +451,7 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { // Process each package argument let mut additions: Vec<(String, String, bool)> = Vec::new(); // (name, constraint, is_dev) - for pkg_arg in &args.packages { + 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), @@ -360,7 +608,7 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { eprintln!("Resolving dependencies..."); // Run resolver - let resolved = match resolver::resolve(&request) { + let mut resolved = match resolver::resolve(&request) { Ok(packages) => packages, Err(e) => { eprintln!("{}", console::error(&e.to_string())); @@ -368,7 +616,7 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { } }; - // Read old lock file (if any) for change reporting + // 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), @@ -387,6 +635,30 @@ pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> { 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<String> = + additions.iter().map(|(name, _, _)| name.clone()).collect(); + + let allow_list = if with_all_deps { + super::update::expand_with_all_dependencies(newly_required, lock) + } else if with_deps { + super::update::expand_with_direct_dependencies(newly_required, lock) + } 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 { |
