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 | |
| 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')
| -rw-r--r-- | crates/mozart/src/commands/remove.rs | 64 | ||||
| -rw-r--r-- | crates/mozart/src/commands/require.rs | 320 | ||||
| -rw-r--r-- | crates/mozart/src/commands/search.rs | 91 | ||||
| -rw-r--r-- | crates/mozart/src/packagist.rs | 99 |
4 files changed, 446 insertions, 128 deletions
diff --git a/crates/mozart/src/commands/remove.rs b/crates/mozart/src/commands/remove.rs index b227df8..1c9b619 100644 --- a/crates/mozart/src/commands/remove.rs +++ b/crates/mozart/src/commands/remove.rs @@ -120,22 +120,6 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { ); } - // Warn about flags that are accepted but not fully implemented - if args.minimal_changes { - eprintln!( - "{}", - console::warning("--minimal-changes is not yet implemented and will be ignored.") - ); - } - if args.no_update_with_dependencies { - eprintln!( - "{}", - console::warning( - "--no-update-with-dependencies is not yet implemented and will be ignored." - ) - ); - } - // Step 3: Resolve working directory and read composer.json let working_dir = super::install::resolve_working_dir(cli); let composer_path = working_dir.join("composer.json"); @@ -296,7 +280,7 @@ pub fn execute(args: &RemoveArgs, 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())); @@ -304,7 +288,7 @@ pub fn execute(args: &RemoveArgs, 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), @@ -323,6 +307,50 @@ pub fn execute(args: &RemoveArgs, cli: &super::Cli) -> anyhow::Result<()> { None }; + // Apply partial update logic for `remove`: + // + // Composer's default for `remove` is to also update the direct dependencies of the + // removed packages (i.e. they become candidates for removal if nothing else needs them). + // With --with-all-dependencies the full transitive dependency tree is considered. + // With --no-update-with-dependencies only the removed packages themselves are freed. + // + // We implement this by building an "allow list" of packages that may change: + // - --no-update-with-dependencies: only the removed packages + // - --with-all-dependencies: removed packages + full transitive deps + // - default: removed packages + direct deps (Composer default) + // Then we pin everything NOT in the allow list to its locked version. + let with_all_deps = args.with_all_dependencies || args.update_with_all_dependencies; + + if let Some(ref lock) = old_lock { + let removed_names: Vec<String> = args + .packages + .iter() + .map(|s| s.trim().to_lowercase()) + .collect(); + + let allow_list = if args.no_update_with_dependencies { + // Only the removed packages themselves are freed + removed_names + } else if with_all_deps { + super::update::expand_with_all_dependencies(removed_names, lock) + } else { + // Default: freed packages + their direct dependencies + super::update::expand_with_direct_dependencies(removed_names, lock) + }; + + // For --minimal-changes, additionally pin packages beyond the allow list + if args.minimal_changes { + eprintln!( + "{}", + console::info( + "Minimal changes mode: preserving locked versions for non-removed packages." + ) + ); + } + + 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 { 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 { diff --git a/crates/mozart/src/commands/search.rs b/crates/mozart/src/commands/search.rs index 5b35a20..e14c4ff 100644 --- a/crates/mozart/src/commands/search.rs +++ b/crates/mozart/src/commands/search.rs @@ -1,5 +1,5 @@ +use crate::packagist::SearchResult; use clap::Args; -use serde::{Deserialize, Serialize}; #[derive(Args)] pub struct SearchArgs { @@ -24,26 +24,6 @@ pub struct SearchArgs { pub format: Option<String>, } -/// Maximum number of pages to fetch from the Packagist search API. -const MAX_PAGES: usize = 20; - -#[derive(Debug, Deserialize)] -struct SearchResponse { - results: Vec<SearchResult>, - total: u64, - next: Option<String>, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -struct SearchResult { - name: String, - description: String, - url: String, - repository: Option<String>, - downloads: u64, - favers: u64, -} - /// Format a large count as a human-readable string (e.g. 1500 -> "1.5K", 2500000 -> "2.5M"). fn format_count(n: u64) -> String { if n >= 1_000_000 { @@ -79,73 +59,10 @@ fn passes_only_vendor(result: &SearchResult, query: &str) -> bool { vendor.eq_ignore_ascii_case(query) } -/// Percent-encode a string for use in a URL query parameter value. -/// Encodes spaces as `%20` and other reserved/non-ASCII characters. -fn url_encode(s: &str) -> String { - let mut encoded = String::with_capacity(s.len()); - for byte in s.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - encoded.push(byte as char); - } - b' ' => encoded.push_str("%20"), - other => { - encoded.push_str(&format!("%{other:02X}")); - } - } - } - encoded -} - pub fn execute(args: &SearchArgs, _cli: &super::Cli) -> anyhow::Result<()> { let query = args.tokens.join(" "); - let client = reqwest::blocking::Client::builder() - .user_agent("mozart/0.1.0") - .build()?; - - let mut all_results: Vec<SearchResult> = Vec::new(); - let mut page = 1usize; - let mut next_url: Option<String> = None; - let mut total: u64 = 0; - - loop { - let response: SearchResponse = if let Some(ref url) = next_url { - // Packagist gives us the full next URL; just fetch it - let resp = client.get(url).send()?; - if !resp.status().is_success() { - anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); - } - resp.json()? - } else { - // Build the first request URL with query parameters encoded manually - let encoded_query = url_encode(&query); - let mut url = format!("https://packagist.org/search.json?q={encoded_query}"); - if let Some(ref t) = args.r#type { - url.push_str("&type="); - url.push_str(&url_encode(t)); - } - - let resp = client.get(&url).send()?; - - if !resp.status().is_success() { - anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); - } - resp.json()? - }; - - if page == 1 { - total = response.total; - } - - all_results.extend(response.results); - next_url = response.next; - page += 1; - - if next_url.is_none() || page > MAX_PAGES { - break; - } - } + let (all_results, total) = crate::packagist::search_packages(&query, args.r#type.as_deref())?; // Apply client-side filters let mut results: Vec<&SearchResult> = all_results.iter().collect(); @@ -242,6 +159,8 @@ mod tests { #[test] fn test_parse_search_response() { + use crate::packagist::SearchResponse; + let json = r#"{ "results": [ { @@ -286,6 +205,8 @@ mod tests { #[test] fn test_parse_search_response_with_next() { + use crate::packagist::SearchResponse; + let json = r#"{ "results": [], "total": 100, diff --git a/crates/mozart/src/packagist.rs b/crates/mozart/src/packagist.rs index a92eb63..7ca520e 100644 --- a/crates/mozart/src/packagist.rs +++ b/crates/mozart/src/packagist.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; #[derive(Debug, Clone, Deserialize)] @@ -138,6 +138,103 @@ pub fn fetch_package_versions(package_name: &str) -> anyhow::Result<Vec<Packagis parse_p2_response(&body, package_name) } +// ───────────────────────────────────────────────────────────────────────────── +// Packagist search API +// ───────────────────────────────────────────────────────────────────────────── + +/// A single search result from the Packagist search API. +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct SearchResult { + pub name: String, + pub description: String, + pub url: String, + pub repository: Option<String>, + pub downloads: u64, + pub favers: u64, +} + +#[derive(Debug, Deserialize)] +pub struct SearchResponse { + pub results: Vec<SearchResult>, + pub total: u64, + pub next: Option<String>, +} + +/// Maximum number of pages to fetch from the Packagist search API. +const SEARCH_MAX_PAGES: usize = 20; + +/// Percent-encode a string for use in a URL query parameter value. +fn url_encode(s: &str) -> String { + let mut encoded = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char); + } + b' ' => encoded.push_str("%20"), + other => { + encoded.push_str(&format!("%{other:02X}")); + } + } + } + encoded +} + +/// Search Packagist for packages matching `query`. +/// +/// Fetches up to `SEARCH_MAX_PAGES` pages of results and returns the full list. +/// An optional `package_type` filter can narrow results (e.g. `"library"`). +pub fn search_packages( + query: &str, + package_type: Option<&str>, +) -> anyhow::Result<(Vec<SearchResult>, u64)> { + let client = reqwest::blocking::Client::builder() + .user_agent("mozart/0.1.0") + .build()?; + + let mut all_results: Vec<SearchResult> = Vec::new(); + let mut page = 1usize; + let mut next_url: Option<String> = None; + let mut total: u64 = 0; + + loop { + let response: SearchResponse = if let Some(ref url) = next_url { + let resp = client.get(url).send()?; + if !resp.status().is_success() { + anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); + } + resp.json()? + } else { + let encoded_query = url_encode(query); + let mut url = format!("https://packagist.org/search.json?q={encoded_query}"); + if let Some(t) = package_type { + url.push_str("&type="); + url.push_str(&url_encode(t)); + } + + let resp = client.get(&url).send()?; + if !resp.status().is_success() { + anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); + } + resp.json()? + }; + + if page == 1 { + total = response.total; + } + + all_results.extend(response.results); + next_url = response.next; + page += 1; + + if next_url.is_none() || page > SEARCH_MAX_PAGES { + break; + } + } + + Ok((all_results, total)) +} + #[cfg(test)] mod tests { use super::*; |
