aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 15:19:18 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 15:19:18 +0900
commitc04744719bd16d9414a9f9a358691d03a993670c (patch)
tree2354da304c97094bfbe23bb38ceba08d60ea19a2 /crates
parentc07e2073e0484924f80f3bb68ea95ce127b42df6 (diff)
downloadphp-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.rs64
-rw-r--r--crates/mozart/src/commands/require.rs320
-rw-r--r--crates/mozart/src/commands/search.rs91
-rw-r--r--crates/mozart/src/packagist.rs99
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::*;