diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-22 15:00:14 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-22 15:00:14 +0900 |
| commit | bdb86358ac558e988ead2d6387e92a78a54eebc4 (patch) | |
| tree | 591daeb119868027d7425a3e3c05d850fb847448 /crates/mozart/src/commands/init.rs | |
| parent | f8358b7c94e52da868a223832f1ccc417b4beedf (diff) | |
| download | php-mozart-bdb86358ac558e988ead2d6387e92a78a54eebc4.tar.gz php-mozart-bdb86358ac558e988ead2d6387e92a78a54eebc4.tar.zst php-mozart-bdb86358ac558e988ead2d6387e92a78a54eebc4.zip | |
feat(init): add interactive dependency selection via Packagist search
Replace the TODO placeholders with a full interactive search-and-pick
loop for both require and require-dev dependencies during `init`.
Users can search Packagist, pick packages by number or name, and
version constraints are auto-detected from available versions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src/commands/init.rs')
| -rw-r--r-- | crates/mozart/src/commands/init.rs | 242 |
1 files changed, 235 insertions, 7 deletions
diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs index 1e8688f..e7c0402 100644 --- a/crates/mozart/src/commands/init.rs +++ b/crates/mozart/src/commands/init.rs @@ -2,9 +2,11 @@ use anyhow::{Context, bail}; use clap::Args; use colored::Colorize; use mozart_core::console; -use mozart_core::package::{self, RawAuthor, RawAutoload, RawPackageData, RawRepository}; +use mozart_core::package::{self, RawAuthor, RawAutoload, RawPackageData, RawRepository, Stability}; use mozart_core::validation; +use mozart_registry::{packagist, version}; use std::collections::BTreeMap; +use std::io::{BufRead, Write}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -80,7 +82,7 @@ pub async fn execute( } let composer = if console.interactive { - build_interactive(args, console, &working_dir)? + build_interactive(args, console, &working_dir).await? } else { build_non_interactive(args, &working_dir)? }; @@ -189,7 +191,7 @@ fn build_non_interactive(args: &InitArgs, working_dir: &Path) -> anyhow::Result< Ok(composer) } -fn build_interactive( +async fn build_interactive( args: &InitArgs, console: &console::Console, working_dir: &Path, @@ -321,18 +323,41 @@ fn build_interactive( }; // Dependencies - // TODO: support selecting dependencies interactively + let preferred_stability = minimum_stability + .as_deref() + .map(Stability::parse) + .unwrap_or(Stability::Stable); + console.info(""); console.info(&format!( "{}", mozart_core::console::info("Define your dependencies.") )); console.info(""); - let require = parse_requirements(&args.require)?; + + let mut require = parse_requirements(&args.require)?; + let interactive_require = + interactive_search_packages("require", &require, preferred_stability).await?; + for (name, constraint) in interactive_require { + require.insert(name, constraint); + } // Dev Dependencies - // TODO: support selecting dependencies interactively - let require_dev = parse_requirements(&args.require_dev)?; + console.info(""); + console.info(&format!( + "{}", + mozart_core::console::info("Define your dev dependencies.") + )); + console.info(""); + + let mut require_dev = parse_requirements(&args.require_dev)?; + let all_required: BTreeMap<String, String> = + require.iter().chain(require_dev.iter()).map(|(k, v)| (k.clone(), v.clone())).collect(); + let interactive_dev = + interactive_search_packages("require-dev", &all_required, preferred_stability).await?; + for (name, constraint) in interactive_dev { + require_dev.insert(name, constraint); + } // PSR-4 Autoload let default_autoload = args.autoload.clone().unwrap_or_else(|| "src/".to_string()); @@ -373,6 +398,209 @@ fn build_interactive( Ok(composer) } +/// Interactive search-and-pick loop for dependencies. +/// +/// Returns a map of package name → version constraint selected by the user. +async fn interactive_search_packages( + label: &str, + already_required: &BTreeMap<String, String>, + preferred_stability: Stability, +) -> anyhow::Result<BTreeMap<String, String>> { + let stdin = std::io::stdin(); + let mut selected: BTreeMap<String, String> = BTreeMap::new(); + + loop { + eprint!("Search for a package to {label} (or leave blank to skip): "); + 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, + } + }; + + if query.is_empty() { + break; + } + + // Search Packagist + let (results, total) = match packagist::search_packages(&query, None).await { + Ok(r) => r, + Err(e) => { + eprintln!( + "{}", + console::warning(&format!("Search failed: {e}. Try again.")) + ); + continue; + } + }; + + // Filter out packages already required + let filtered: Vec<&packagist::SearchResult> = results + .iter() + .filter(|r| { + let name = r.name.to_lowercase(); + !already_required.contains_key(&name) && !selected.contains_key(&name) + }) + .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() { + break; + } + + // Resolve chosen package name + let package_name: String = if let Ok(num) = choice.parse::<usize>() { + if num == 0 { + continue; + } else if num <= filtered.len() { + filtered[num - 1].name.to_lowercase() + } else { + eprintln!("{}", console::warning(&format!("Invalid selection: {num}"))); + continue; + } + } else { + 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, None).await { + Ok(versions) => { + match version::find_best_candidate(&versions, preferred_stability) { + Some(best) => { + let stability = version::stability_of(&best.version_normalized); + let c = 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.insert(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) +} + fn get_default_package_name(working_dir: &Path) -> String { let dir_name = working_dir .file_name() |
