aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 17:23:39 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 17:23:39 +0900
commitca27b7ca262b0115bea9ced660fb9a9e52b462c4 (patch)
tree21937fa1c1de525b1ff17bcf8cc81903f3ac9350
parentb88a44b75e1cb8542ea0a399a287006d8d4754d8 (diff)
downloadphp-mozart-ca27b7ca262b0115bea9ced660fb9a9e52b462c4.tar.gz
php-mozart-ca27b7ca262b0115bea9ced660fb9a9e52b462c4.tar.zst
php-mozart-ca27b7ca262b0115bea9ced660fb9a9e52b462c4.zip
feat(suggests): implement suggests command with filtering and multiple output modes
Replaces the todo\!() stub with full implementation that collects suggested packages from lock file, installed packages, and root composer.json. Supports --no-dev, --all, --list, --by-package, --by-suggestion flags and package name filtering. Filters out already-installed and platform packages. Includes 11 tests covering collection, filtering, and edge cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--crates/mozart/src/commands/suggests.rs786
1 files changed, 784 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/suggests.rs b/crates/mozart/src/commands/suggests.rs
index 7c5f40d..8d0898a 100644
--- a/crates/mozart/src/commands/suggests.rs
+++ b/crates/mozart/src/commands/suggests.rs
@@ -1,4 +1,6 @@
use clap::Args;
+use std::collections::{BTreeMap, HashSet};
+use std::path::{Path, PathBuf};
#[derive(Args)]
pub struct SuggestsArgs {
@@ -26,6 +28,786 @@ pub struct SuggestsArgs {
pub no_dev: bool,
}
-pub fn execute(_args: &SuggestsArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─── Data structures ─────────────────────────────────────────────────────────
+
+struct Suggestion {
+ source: String, // package making the suggestion
+ target: String, // suggested package name
+ reason: String, // human-readable reason (may be empty)
+}
+
+// ─── Main entry point ────────────────────────────────────────────────────────
+
+pub fn execute(args: &SuggestsArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ let lock_path = working_dir.join("composer.lock");
+ let has_lock = lock_path.exists();
+
+ // 1. Collect raw suggestions from locked or installed packages
+ let mut suggestions: Vec<Suggestion> = if has_lock {
+ collect_suggestions_from_locked(&working_dir, args.no_dev)?
+ } else {
+ collect_suggestions_from_installed(&working_dir, args.no_dev)?
+ };
+
+ // Also collect root package's own suggestions
+ let root_suggestions = collect_suggestions_from_root(&working_dir)?;
+ suggestions.extend(root_suggestions);
+
+ // 2. Collect installed names for filtering
+ let installed_names = if has_lock {
+ collect_installed_names_from_lock(&working_dir, args.no_dev)?
+ } else {
+ collect_installed_names_from_installed(&working_dir, args.no_dev)?
+ };
+
+ // 3. Determine direct-deps-only filter
+ let (package_filter, direct_deps_only): (HashSet<String>, Option<HashSet<String>>) = {
+ if !args.packages.is_empty() {
+ // Filter by the explicitly named packages
+ let filter: HashSet<String> = args.packages.iter().map(|s| s.to_lowercase()).collect();
+ (filter, None)
+ } else if args.all {
+ (HashSet::new(), None)
+ } else {
+ // Default: only direct deps from composer.json
+ let direct = compute_direct_deps(&working_dir)?;
+ (HashSet::new(), Some(direct))
+ }
+ };
+
+ // Count total suggestions before filtering by direct deps
+ let total_before_direct_filter = if direct_deps_only.is_some() {
+ // Count how many would survive without the direct-deps filter
+ suggestions
+ .iter()
+ .filter(|s| !installed_names.contains(&s.target.to_lowercase()))
+ .filter(|s| {
+ if !package_filter.is_empty() {
+ package_filter.contains(&s.source.to_lowercase())
+ } else {
+ true
+ }
+ })
+ .count()
+ } else {
+ 0
+ };
+
+ // 4. Filter suggestions
+ let filtered: Vec<&Suggestion> = suggestions
+ .iter()
+ .filter(|s| {
+ // Skip if target is already installed
+ if installed_names.contains(&s.target.to_lowercase()) {
+ return false;
+ }
+ // If package_filter is non-empty, skip if source not in filter
+ if !package_filter.is_empty() && !package_filter.contains(&s.source.to_lowercase()) {
+ return false;
+ }
+ // If direct_deps_only is Some, skip if source not in that set
+ if let Some(ref direct) = direct_deps_only
+ && !direct.contains(&s.source.to_lowercase())
+ {
+ return false;
+ }
+ true
+ })
+ .collect();
+
+ // 5. Print info message about transitive suggestions
+ if direct_deps_only.is_some() {
+ let shown = filtered.len();
+ let diff = total_before_direct_filter.saturating_sub(shown);
+ if diff > 0 {
+ println!(
+ "{} additional suggestions by transitive dependencies can be shown with --all",
+ diff
+ );
+ }
+ }
+
+ // 6. Render output
+ if args.list {
+ render_list(&filtered);
+ } else if args.by_suggestion && !args.by_package {
+ render_by_suggestion(&filtered);
+ } else if args.by_package && args.by_suggestion {
+ render_by_package(&filtered);
+ println!("---");
+ render_by_suggestion(&filtered);
+ } else {
+ // Default: by-package
+ render_by_package(&filtered);
+ }
+
+ Ok(())
+}
+
+// ─── Suggestion collection ───────────────────────────────────────────────────
+
+fn collect_suggestions_from_locked(
+ working_dir: &Path,
+ no_dev: bool,
+) -> anyhow::Result<Vec<Suggestion>> {
+ let lock_path = working_dir.join("composer.lock");
+ let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?;
+
+ let mut all_packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect();
+ if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev {
+ all_packages.extend(pkgs_dev.iter());
+ }
+
+ let mut result = Vec::new();
+ for pkg in all_packages {
+ if let Some(ref suggest_map) = pkg.suggest {
+ for (target, reason) in suggest_map {
+ result.push(Suggestion {
+ source: pkg.name.clone(),
+ target: target.clone(),
+ reason: reason.clone(),
+ });
+ }
+ }
+ }
+ Ok(result)
+}
+
+fn collect_suggestions_from_installed(
+ working_dir: &Path,
+ no_dev: bool,
+) -> anyhow::Result<Vec<Suggestion>> {
+ let vendor_dir = working_dir.join("vendor");
+ let installed = crate::installed::InstalledPackages::read(&vendor_dir)?;
+
+ if installed.packages.is_empty() {
+ let installed_json = vendor_dir.join("composer/installed.json");
+ if !installed_json.exists() {
+ anyhow::bail!(
+ "No composer.lock and no installed.json found. \
+ Run `mozart install` first."
+ );
+ }
+ }
+
+ let dev_names: HashSet<String> = installed
+ .dev_package_names
+ .iter()
+ .map(|n| n.to_lowercase())
+ .collect();
+
+ let mut result = Vec::new();
+ for pkg in &installed.packages {
+ if no_dev && dev_names.contains(&pkg.name.to_lowercase()) {
+ continue;
+ }
+ // suggest is stored in extra_fields as a JSON object
+ if let Some(suggest_val) = pkg.extra_fields.get("suggest")
+ && let Some(obj) = suggest_val.as_object()
+ {
+ for (target, reason_val) in obj {
+ let reason = reason_val.as_str().unwrap_or("").to_string();
+ result.push(Suggestion {
+ source: pkg.name.clone(),
+ target: target.clone(),
+ reason,
+ });
+ }
+ }
+ }
+ Ok(result)
+}
+
+fn collect_suggestions_from_root(working_dir: &Path) -> anyhow::Result<Vec<Suggestion>> {
+ let composer_json_path = working_dir.join("composer.json");
+ if !composer_json_path.exists() {
+ return Ok(vec![]);
+ }
+
+ let root = crate::package::read_from_file(&composer_json_path)?;
+
+ // suggest is in extra_fields since RawPackageData doesn't model it explicitly
+ let suggest_val = root.extra_fields.get("suggest");
+ let Some(suggest_val) = suggest_val else {
+ return Ok(vec![]);
+ };
+
+ let Some(obj) = suggest_val.as_object() else {
+ return Ok(vec![]);
+ };
+
+ let mut result = Vec::new();
+ for (target, reason_val) in obj {
+ let reason = reason_val.as_str().unwrap_or("").to_string();
+ result.push(Suggestion {
+ source: root.name.clone(),
+ target: target.clone(),
+ reason,
+ });
+ }
+ Ok(result)
+}
+
+// ─── Installed name collection ───────────────────────────────────────────────
+
+fn collect_installed_names_from_lock(
+ working_dir: &Path,
+ no_dev: bool,
+) -> anyhow::Result<HashSet<String>> {
+ let lock_path = working_dir.join("composer.lock");
+ let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?;
+
+ let mut names: HashSet<String> = HashSet::new();
+
+ let mut all_packages: Vec<&crate::lockfile::LockedPackage> = lock.packages.iter().collect();
+ if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev {
+ all_packages.extend(pkgs_dev.iter());
+ }
+
+ for pkg in all_packages {
+ names.insert(pkg.name.to_lowercase());
+
+ // Also add provide and replace virtual package names
+ for key in pkg.extra_fields.keys() {
+ if (key == "provide" || key == "replace")
+ && let Some(obj) = pkg.extra_fields[key].as_object()
+ {
+ for name in obj.keys() {
+ names.insert(name.to_lowercase());
+ }
+ }
+ }
+ }
+
+ // Add platform packages (any package starting with php, ext-, lib-)
+ add_platform_names_from_lock(&lock, &mut names);
+
+ Ok(names)
+}
+
+fn collect_installed_names_from_installed(
+ working_dir: &Path,
+ no_dev: bool,
+) -> anyhow::Result<HashSet<String>> {
+ let vendor_dir = working_dir.join("vendor");
+ let installed = crate::installed::InstalledPackages::read(&vendor_dir)?;
+
+ let dev_names: HashSet<String> = installed
+ .dev_package_names
+ .iter()
+ .map(|n| n.to_lowercase())
+ .collect();
+
+ let mut names: HashSet<String> = HashSet::new();
+
+ for pkg in &installed.packages {
+ if no_dev && dev_names.contains(&pkg.name.to_lowercase()) {
+ continue;
+ }
+ names.insert(pkg.name.to_lowercase());
+
+ // provide / replace
+ for key in &["provide", "replace"] {
+ if let Some(val) = pkg.extra_fields.get(*key)
+ && let Some(obj) = val.as_object()
+ {
+ for name in obj.keys() {
+ names.insert(name.to_lowercase());
+ }
+ }
+ }
+ }
+
+ // Add platform packages from require/require-dev in composer.json
+ let composer_json_path = working_dir.join("composer.json");
+ if composer_json_path.exists()
+ && let Ok(root) = crate::package::read_from_file(&composer_json_path)
+ {
+ for name in root.require.keys().chain(root.require_dev.keys()) {
+ if is_platform_package(name) {
+ names.insert(name.to_lowercase());
+ }
+ }
+ }
+
+ Ok(names)
+}
+
+fn add_platform_names_from_lock(lock: &crate::lockfile::LockFile, names: &mut HashSet<String>) {
+ // Collect platform keys from the lock's platform and platform_dev objects
+ if let Some(obj) = lock.platform.as_object() {
+ for key in obj.keys() {
+ if is_platform_package(key) {
+ names.insert(key.to_lowercase());
+ }
+ }
+ }
+ if let Some(obj) = lock.platform_dev.as_object() {
+ for key in obj.keys() {
+ if is_platform_package(key) {
+ names.insert(key.to_lowercase());
+ }
+ }
+ }
+}
+
+fn is_platform_package(name: &str) -> bool {
+ let n = name.to_lowercase();
+ n == "php" || n.starts_with("php-") || n.starts_with("ext-") || n.starts_with("lib-")
+}
+
+// ─── Direct deps helper ───────────────────────────────────────────────────────
+
+fn compute_direct_deps(working_dir: &Path) -> anyhow::Result<HashSet<String>> {
+ let composer_json_path = working_dir.join("composer.json");
+ if !composer_json_path.exists() {
+ return Ok(HashSet::new());
+ }
+ let root = crate::package::read_from_file(&composer_json_path)?;
+ let mut deps: HashSet<String> = HashSet::new();
+ for name in root.require.keys().chain(root.require_dev.keys()) {
+ deps.insert(name.to_lowercase());
+ }
+ Ok(deps)
+}
+
+// ─── Rendering ───────────────────────────────────────────────────────────────
+
+fn render_list(suggestions: &[&Suggestion]) {
+ let mut targets: Vec<&str> = suggestions.iter().map(|s| s.target.as_str()).collect();
+ targets.sort_unstable();
+ targets.dedup();
+ for t in targets {
+ println!("{}", t);
+ }
+}
+
+fn render_by_package(suggestions: &[&Suggestion]) {
+ // Group by source, preserving insertion order via BTreeMap (sorted)
+ let mut grouped: BTreeMap<&str, Vec<&Suggestion>> = BTreeMap::new();
+ for s in suggestions {
+ grouped.entry(s.source.as_str()).or_default().push(s);
+ }
+ for (source, items) in &grouped {
+ println!("{} suggests:", source);
+ for s in items {
+ if s.reason.is_empty() {
+ println!(" - {}", s.target);
+ } else {
+ println!(" - {}: {}", s.target, s.reason);
+ }
+ }
+ }
+}
+
+fn render_by_suggestion(suggestions: &[&Suggestion]) {
+ // Group by target
+ let mut grouped: BTreeMap<&str, Vec<&Suggestion>> = BTreeMap::new();
+ for s in suggestions {
+ grouped.entry(s.target.as_str()).or_default().push(s);
+ }
+ for (target, items) in &grouped {
+ println!("{} is suggested by:", target);
+ for s in items {
+ if s.reason.is_empty() {
+ println!(" - {}", s.source);
+ } else {
+ println!(" - {}: {}", s.source, s.reason);
+ }
+ }
+ }
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeMap;
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ fn make_suggestion(source: &str, target: &str, reason: &str) -> Suggestion {
+ Suggestion {
+ source: source.to_string(),
+ target: target.to_string(),
+ reason: reason.to_string(),
+ }
+ }
+
+ fn make_locked_package(
+ name: &str,
+ suggest: Option<BTreeMap<String, String>>,
+ ) -> crate::lockfile::LockedPackage {
+ crate::lockfile::LockedPackage {
+ name: name.to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ require: BTreeMap::new(),
+ require_dev: BTreeMap::new(),
+ conflict: BTreeMap::new(),
+ suggest,
+ package_type: None,
+ autoload: None,
+ autoload_dev: None,
+ license: None,
+ description: None,
+ homepage: None,
+ keywords: None,
+ authors: None,
+ support: None,
+ funding: None,
+ time: None,
+ extra_fields: BTreeMap::new(),
+ }
+ }
+
+ fn make_installed_entry(
+ name: &str,
+ suggest: Option<BTreeMap<String, String>>,
+ ) -> crate::installed::InstalledPackageEntry {
+ let mut extra_fields: BTreeMap<String, serde_json::Value> = BTreeMap::new();
+ if let Some(s) = suggest {
+ let map: serde_json::Map<String, serde_json::Value> = s
+ .into_iter()
+ .map(|(k, v)| (k, serde_json::Value::String(v)))
+ .collect();
+ extra_fields.insert("suggest".to_string(), serde_json::Value::Object(map));
+ }
+ crate::installed::InstalledPackageEntry {
+ name: name.to_string(),
+ version: "1.0.0".to_string(),
+ version_normalized: None,
+ source: None,
+ dist: None,
+ package_type: None,
+ install_path: None,
+ autoload: None,
+ aliases: vec![],
+ extra_fields,
+ }
+ }
+
+ fn minimal_lock(
+ packages: Vec<crate::lockfile::LockedPackage>,
+ packages_dev: Option<Vec<crate::lockfile::LockedPackage>>,
+ ) -> crate::lockfile::LockFile {
+ crate::lockfile::LockFile {
+ readme: crate::lockfile::LockFile::default_readme(),
+ content_hash: "abc123".to_string(),
+ packages,
+ packages_dev,
+ aliases: vec![],
+ minimum_stability: "stable".to_string(),
+ stability_flags: serde_json::json!({}),
+ prefer_stable: false,
+ prefer_lowest: false,
+ platform: serde_json::json!({}),
+ platform_dev: serde_json::json!({}),
+ plugin_api_version: Some("2.6.0".to_string()),
+ }
+ }
+
+ // ── Filter tests ──────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_filter_removes_installed_targets() {
+ let suggestions = vec![
+ make_suggestion("vendor/a", "ext-intl", "for internationalization"),
+ make_suggestion("vendor/b", "vendor/optional", "for extra features"),
+ make_suggestion("vendor/c", "ext-mbstring", "for string processing"),
+ ];
+ let refs: Vec<&Suggestion> = suggestions.iter().collect();
+
+ let mut installed: HashSet<String> = HashSet::new();
+ installed.insert("ext-intl".to_string());
+ installed.insert("ext-mbstring".to_string());
+
+ let filtered: Vec<&Suggestion> = refs
+ .iter()
+ .copied()
+ .filter(|s| !installed.contains(&s.target.to_lowercase()))
+ .collect();
+
+ assert_eq!(filtered.len(), 1);
+ assert_eq!(filtered[0].target, "vendor/optional");
+ }
+
+ #[test]
+ fn test_filter_by_package_names() {
+ let suggestions = vec![
+ make_suggestion("vendor/a", "vendor/x", "reason"),
+ make_suggestion("vendor/b", "vendor/y", "reason"),
+ make_suggestion("vendor/c", "vendor/z", "reason"),
+ ];
+ let refs: Vec<&Suggestion> = suggestions.iter().collect();
+
+ let mut filter: HashSet<String> = HashSet::new();
+ filter.insert("vendor/a".to_string());
+ filter.insert("vendor/c".to_string());
+
+ let filtered: Vec<&Suggestion> = refs
+ .iter()
+ .copied()
+ .filter(|s| filter.contains(&s.source.to_lowercase()))
+ .collect();
+
+ assert_eq!(filtered.len(), 2);
+ assert_eq!(filtered[0].source, "vendor/a");
+ assert_eq!(filtered[1].source, "vendor/c");
+ }
+
+ #[test]
+ fn test_filter_direct_deps_only() {
+ let suggestions = vec![
+ make_suggestion("vendor/direct", "vendor/x", "reason"),
+ make_suggestion("vendor/transitive", "vendor/y", "reason"),
+ ];
+ let refs: Vec<&Suggestion> = suggestions.iter().collect();
+
+ let mut direct: HashSet<String> = HashSet::new();
+ direct.insert("vendor/direct".to_string());
+
+ let filtered: Vec<&Suggestion> = refs
+ .iter()
+ .copied()
+ .filter(|s| direct.contains(&s.source.to_lowercase()))
+ .collect();
+
+ assert_eq!(filtered.len(), 1);
+ assert_eq!(filtered[0].source, "vendor/direct");
+ }
+
+ #[test]
+ fn test_filter_no_filter() {
+ let suggestions = vec![
+ make_suggestion("vendor/a", "vendor/x", ""),
+ make_suggestion("vendor/b", "vendor/y", ""),
+ make_suggestion("vendor/c", "vendor/z", ""),
+ ];
+ let refs: Vec<&Suggestion> = suggestions.iter().collect();
+ let installed: HashSet<String> = HashSet::new();
+
+ let filtered: Vec<&Suggestion> = refs
+ .iter()
+ .copied()
+ .filter(|s| !installed.contains(&s.target.to_lowercase()))
+ .collect();
+
+ assert_eq!(filtered.len(), 3);
+ }
+
+ // ── Collection tests ──────────────────────────────────────────────────────
+
+ #[test]
+ fn test_suggests_from_lockfile() {
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ let mut suggest_a = BTreeMap::new();
+ suggest_a.insert(
+ "ext-intl".to_string(),
+ "For internationalization".to_string(),
+ );
+ suggest_a.insert(
+ "vendor/optional".to_string(),
+ "Optional features".to_string(),
+ );
+
+ let lock = minimal_lock(
+ vec![make_locked_package("vendor/a", Some(suggest_a))],
+ Some(vec![]),
+ );
+ lock.write_to_file(&working_dir.join("composer.lock"))
+ .unwrap();
+
+ let suggestions = collect_suggestions_from_locked(working_dir, false).unwrap();
+ assert_eq!(suggestions.len(), 2);
+ assert!(suggestions.iter().all(|s| s.source == "vendor/a"));
+ let targets: HashSet<&str> = suggestions.iter().map(|s| s.target.as_str()).collect();
+ assert!(targets.contains("ext-intl"));
+ assert!(targets.contains("vendor/optional"));
+ }
+
+ #[test]
+ fn test_suggests_from_lockfile_no_dev() {
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ let mut prod_suggest = BTreeMap::new();
+ prod_suggest.insert(
+ "vendor/prod-opt".to_string(),
+ "Production option".to_string(),
+ );
+
+ let mut dev_suggest = BTreeMap::new();
+ dev_suggest.insert("vendor/dev-opt".to_string(), "Dev option".to_string());
+
+ let lock = minimal_lock(
+ vec![make_locked_package("vendor/prod", Some(prod_suggest))],
+ Some(vec![make_locked_package("vendor/dev", Some(dev_suggest))]),
+ );
+ lock.write_to_file(&working_dir.join("composer.lock"))
+ .unwrap();
+
+ // With no_dev=true: only production suggestions
+ let suggestions = collect_suggestions_from_locked(working_dir, true).unwrap();
+ assert_eq!(suggestions.len(), 1);
+ assert_eq!(suggestions[0].source, "vendor/prod");
+ assert_eq!(suggestions[0].target, "vendor/prod-opt");
+
+ // With no_dev=false: both
+ let suggestions_all = collect_suggestions_from_locked(working_dir, false).unwrap();
+ assert_eq!(suggestions_all.len(), 2);
+ }
+
+ #[test]
+ fn test_suggests_from_installed() {
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+ let vendor_dir = working_dir.join("vendor");
+
+ let mut suggest = BTreeMap::new();
+ suggest.insert("ext-redis".to_string(), "For Redis caching".to_string());
+
+ let mut installed = crate::installed::InstalledPackages::new();
+ installed.upsert(make_installed_entry("vendor/cache", Some(suggest)));
+ installed.upsert(make_installed_entry("vendor/other", None));
+ installed.write(&vendor_dir).unwrap();
+
+ let suggestions = collect_suggestions_from_installed(working_dir, false).unwrap();
+ assert_eq!(suggestions.len(), 1);
+ assert_eq!(suggestions[0].source, "vendor/cache");
+ assert_eq!(suggestions[0].target, "ext-redis");
+ assert_eq!(suggestions[0].reason, "For Redis caching");
+ }
+
+ #[test]
+ fn test_suggests_from_root() {
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ let composer_json = serde_json::json!({
+ "name": "my/project",
+ "require": {},
+ "suggest": {
+ "vendor/optional-pkg": "Provides extra functionality"
+ }
+ });
+ std::fs::write(
+ working_dir.join("composer.json"),
+ serde_json::to_string_pretty(&composer_json).unwrap(),
+ )
+ .unwrap();
+
+ let suggestions = collect_suggestions_from_root(working_dir).unwrap();
+ assert_eq!(suggestions.len(), 1);
+ assert_eq!(suggestions[0].source, "my/project");
+ assert_eq!(suggestions[0].target, "vendor/optional-pkg");
+ assert_eq!(suggestions[0].reason, "Provides extra functionality");
+ }
+
+ #[test]
+ fn test_suggests_filters_already_installed() {
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ let mut suggest = BTreeMap::new();
+ suggest.insert(
+ "vendor/already-here".to_string(),
+ "Already installed".to_string(),
+ );
+ suggest.insert(
+ "vendor/not-here".to_string(),
+ "Not yet installed".to_string(),
+ );
+
+ let lock = minimal_lock(
+ vec![
+ make_locked_package("vendor/a", Some(suggest)),
+ make_locked_package("vendor/already-here", None),
+ ],
+ Some(vec![]),
+ );
+ lock.write_to_file(&working_dir.join("composer.lock"))
+ .unwrap();
+
+ let suggestions = collect_suggestions_from_locked(working_dir, false).unwrap();
+ let installed = collect_installed_names_from_lock(working_dir, false).unwrap();
+
+ let filtered: Vec<&Suggestion> = suggestions
+ .iter()
+ .filter(|s| !installed.contains(&s.target.to_lowercase()))
+ .collect();
+
+ assert_eq!(filtered.len(), 1);
+ assert_eq!(filtered[0].target, "vendor/not-here");
+ }
+
+ #[test]
+ fn test_suggests_empty() {
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ let lock = minimal_lock(
+ vec![make_locked_package("vendor/no-suggestions", None)],
+ Some(vec![]),
+ );
+ lock.write_to_file(&working_dir.join("composer.lock"))
+ .unwrap();
+
+ let suggestions = collect_suggestions_from_locked(working_dir, false).unwrap();
+ assert!(suggestions.is_empty());
+ }
+
+ #[test]
+ fn test_collect_installed_names() {
+ use tempfile::tempdir;
+
+ let dir = tempdir().unwrap();
+ let working_dir = dir.path();
+
+ let mut suggest_a = BTreeMap::new();
+ suggest_a.insert("vendor/opt".to_string(), "optional".to_string());
+
+ let lock = minimal_lock(
+ vec![
+ make_locked_package("vendor/pkg-a", Some(suggest_a)),
+ make_locked_package("vendor/pkg-b", None),
+ ],
+ Some(vec![make_locked_package("vendor/pkg-dev", None)]),
+ );
+ lock.write_to_file(&working_dir.join("composer.lock"))
+ .unwrap();
+
+ let names = collect_installed_names_from_lock(working_dir, false).unwrap();
+ assert!(names.contains("vendor/pkg-a"));
+ assert!(names.contains("vendor/pkg-b"));
+ assert!(names.contains("vendor/pkg-dev"));
+
+ // With no_dev=true: dev package excluded
+ let names_no_dev = collect_installed_names_from_lock(working_dir, true).unwrap();
+ assert!(names_no_dev.contains("vendor/pkg-a"));
+ assert!(names_no_dev.contains("vendor/pkg-b"));
+ assert!(!names_no_dev.contains("vendor/pkg-dev"));
+ }
}