diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-22 22:06:07 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-22 22:06:07 +0900 |
| commit | 0833b8972c20e48dd33e9f985cd225e4a9abf5e2 (patch) | |
| tree | fd80df0bcedfc27cbc8d8da130c8ca3ad55feed3 /crates/mozart | |
| parent | ca571851e4c3e08a2e3ae22f8119ab6446abbb1b (diff) | |
| download | php-mozart-0833b8972c20e48dd33e9f985cd225e4a9abf5e2.tar.gz php-mozart-0833b8972c20e48dd33e9f985cd225e4a9abf5e2.tar.zst php-mozart-0833b8972c20e48dd33e9f985cd225e4a9abf5e2.zip | |
fix(suggests): add deduplication and ANSI color output
Deduplicate suggestions by (source, target) pair matching Composer's
map-based approach where last reason wins. Add ANSI color formatting
using console::info (green) and console::comment (yellow) for package
names, suggesters, and the transitive hint message.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart')
| -rw-r--r-- | crates/mozart/src/commands/suggests.rs | 90 |
1 files changed, 79 insertions, 11 deletions
diff --git a/crates/mozart/src/commands/suggests.rs b/crates/mozart/src/commands/suggests.rs index 9150dd5..b61e1c3 100644 --- a/crates/mozart/src/commands/suggests.rs +++ b/crates/mozart/src/commands/suggests.rs @@ -1,5 +1,6 @@ use clap::Args; -use std::collections::{BTreeMap, HashSet}; +use mozart_core::console; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; #[derive(Args)] @@ -41,7 +42,7 @@ struct Suggestion { pub async fn execute( args: &SuggestsArgs, cli: &super::Cli, - _console: &mozart_core::console::Console, + _console: &console::Console, ) -> anyhow::Result<()> { let working_dir = match &cli.working_dir { Some(dir) => PathBuf::from(dir), @@ -62,6 +63,9 @@ pub async fn execute( let root_suggestions = collect_suggestions_from_root(&working_dir)?; suggestions.extend(root_suggestions); + // Deduplicate by (source, target) pair — last reason wins (Composer behavior) + let suggestions = deduplicate_suggestions(suggestions); + // 2. Collect installed names for filtering let installed_names = if has_lock { collect_installed_names_from_lock(&working_dir, args.no_dev)? @@ -130,8 +134,9 @@ pub async fn execute( let diff = total_before_direct_filter.saturating_sub(shown); if diff > 0 { println!( - "{} additional suggestions by transitive dependencies can be shown with --all", - diff + "{} by transitive dependencies can be shown with {}", + console::info(&format!("{diff} additional suggestions")), + console::info("--all"), ); } } @@ -401,6 +406,28 @@ fn sanitize_reason(reason: &str) -> String { .collect() } +// ─── Deduplication ──────────────────────────────────────────────────────────── + +/// Deduplicate suggestions by (source, target) pair. +/// If the same source suggests the same target multiple times, the last reason wins. +/// This matches Composer's behavior where map insertion overwrites previous entries. +fn deduplicate_suggestions(suggestions: Vec<Suggestion>) -> Vec<Suggestion> { + let mut seen: HashMap<(String, String), usize> = HashMap::new(); + let mut deduped: Vec<Suggestion> = Vec::new(); + + for s in suggestions { + let key = (s.source.to_lowercase(), s.target.to_lowercase()); + if let Some(&idx) = seen.get(&key) { + deduped[idx].reason = s.reason; + } else { + seen.insert(key, deduped.len()); + deduped.push(s); + } + } + + deduped +} + // ─── Rendering ─────────────────────────────────────────────────────────────── fn render_list(suggestions: &[&Suggestion]) { @@ -408,7 +435,7 @@ fn render_list(suggestions: &[&Suggestion]) { targets.sort_unstable(); targets.dedup(); for t in targets { - println!("{}", t); + println!("{}", console::info(t)); } } @@ -419,13 +446,13 @@ fn render_by_package(suggestions: &[&Suggestion]) { grouped.entry(s.source.as_str()).or_default().push(s); } for (source, items) in &grouped { - println!("{} suggests:", source); + println!("{} suggests:", console::comment(source)); for s in items { let reason = sanitize_reason(&s.reason); if reason.is_empty() { - println!(" - {}", s.target); + println!(" - {}", console::info(&s.target)); } else { - println!(" - {}: {}", s.target, reason); + println!(" - {}: {}", console::info(&s.target), reason); } } println!(); @@ -439,13 +466,13 @@ fn render_by_suggestion(suggestions: &[&Suggestion]) { grouped.entry(s.target.as_str()).or_default().push(s); } for (target, items) in &grouped { - println!("{} is suggested by:", target); + println!("{} is suggested by:", console::info(target)); for s in items { let reason = sanitize_reason(&s.reason); if reason.is_empty() { - println!(" - {}", s.source); + println!(" - {}", console::comment(&s.source)); } else { - println!(" - {}: {}", s.source, reason); + println!(" - {}: {}", console::comment(&s.source), reason); } } println!(); @@ -544,6 +571,47 @@ mod tests { } } + // ── Deduplication tests ──────────────────────────────────────────────────── + + #[test] + fn test_deduplicate_keeps_last_reason() { + let suggestions = vec![ + make_suggestion("vendor/a", "ext-intl", "first reason"), + make_suggestion("vendor/b", "ext-redis", "only reason"), + make_suggestion("vendor/a", "ext-intl", "second reason"), + ]; + let deduped = deduplicate_suggestions(suggestions); + assert_eq!(deduped.len(), 2); + // First entry should be vendor/a -> ext-intl with updated reason + assert_eq!(deduped[0].source, "vendor/a"); + assert_eq!(deduped[0].target, "ext-intl"); + assert_eq!(deduped[0].reason, "second reason"); + // Second entry should be vendor/b -> ext-redis + assert_eq!(deduped[1].source, "vendor/b"); + assert_eq!(deduped[1].target, "ext-redis"); + } + + #[test] + fn test_deduplicate_case_insensitive() { + let suggestions = vec![ + make_suggestion("Vendor/A", "Ext-Intl", "first"), + make_suggestion("vendor/a", "ext-intl", "second"), + ]; + let deduped = deduplicate_suggestions(suggestions); + assert_eq!(deduped.len(), 1); + assert_eq!(deduped[0].reason, "second"); + } + + #[test] + fn test_deduplicate_no_duplicates() { + let suggestions = vec![ + make_suggestion("vendor/a", "ext-intl", "reason a"), + make_suggestion("vendor/b", "ext-redis", "reason b"), + ]; + let deduped = deduplicate_suggestions(suggestions); + assert_eq!(deduped.len(), 2); + } + // ── Filter tests ────────────────────────────────────────────────────────── #[test] |
