From 720430c0d08817f1113cbf35b541eaf673679fb9 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 9 May 2026 00:36:24 +0900 Subject: refactor(show): Slice A — align show.rs with Composer's ShowCommand pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A3: wire --major-only/--minor-only/--patch-only version constraint filtering in fetch_latest_for_package (mirrors ShowCommand::findLatestPackage 1500-1515) - A4: implement --sort-by-age ordering by release date - A5: add abandoned-package warning after each list row - A6: print color legend before list when --latest is on; ASCII markers (\! ~ =) in non-decorated mode - A7: split list into "Direct dependencies" / "Transitive dependencies" sections when --latest && \!--direct (mirrors Composer 671-695) - A8: replace local is_platform_package with mozart_core::platform::is_platform_package - A9: emit --installed deprecation warning (mirrors Composer 143-145) - A10: apply --strict exit-code check in both installed and locked paths - A11: fetch and display latest: line in single-package detail view - A12: add conflict/provide/replace link sections to detail view - A13: add released/names/support/autoload fields to detail view - A14: use "locked" JSON key for --locked output (was incorrectly "installed") - A15: unify show_installed_package_detail + show_locked_package_detail into a single PackageDetail struct + print_package_detail function Co-Authored-By: Claude Sonnet 4.6 --- .../src/installer_executor/transaction.rs | 411 +++++ crates/mozart/src/commands/show.rs | 1806 ++++++++++++-------- 2 files changed, 1475 insertions(+), 742 deletions(-) create mode 100644 crates/mozart-registry/src/installer_executor/transaction.rs diff --git a/crates/mozart-registry/src/installer_executor/transaction.rs b/crates/mozart-registry/src/installer_executor/transaction.rs new file mode 100644 index 0000000..95f9718 --- /dev/null +++ b/crates/mozart-registry/src/installer_executor/transaction.rs @@ -0,0 +1,411 @@ +//! Transaction computation — lock-vs-installed diff and alias reconciliation. +//! +//! Mirrors `Composer\DependencyResolver\Transaction::calculateOperations` and +//! `Composer\Installer\InstalledFilesystemRepository` (the `ArrayDumper` +//! path). Kept separate so both `install` and `update` commands can share the +//! same operation-computation machinery without going through the `install` +//! command module. + +use crate::installed::{InstalledPackageEntry, InstalledPackages}; +use crate::lockfile::{LockFile, LockedPackage}; +use indexmap::IndexSet; +use std::path::Path; + +/// The action to take for a package during install. +#[derive(Debug, PartialEq, Eq)] +pub enum Action { + Install, + Update, + Skip, +} + +/// Compute install operations by comparing locked packages against installed packages. +/// +/// Returns `(ops, removals)` where: +/// - `ops`: list of `(package, action)` ordered topologically — every package's +/// lock-internal `require` deps appear before it, matching Composer's +/// `Transaction::calculateOperations`. +/// - `removals`: list of package names that are installed but not locked. +pub fn compute_operations<'a>( + locked: &[&'a LockedPackage], + installed: &InstalledPackages, +) -> (Vec<(&'a LockedPackage, Action)>, Vec) { + let ordered = topological_sort(locked); + + let mut ops: Vec<(&'a LockedPackage, Action)> = Vec::new(); + for pkg in ordered { + let installed_entry = installed + .packages + .iter() + .find(|p| p.name.eq_ignore_ascii_case(&pkg.name)); + let action = match installed_entry { + None => Action::Install, + Some(entry) if entry.version != pkg.version => Action::Update, + Some(entry) if !installed_refs_match_locked(entry, pkg) => Action::Update, + Some(entry) if !installed_abandoned_matches_locked(entry, pkg) => Action::Update, + Some(_) => Action::Skip, + }; + ops.push((pkg, action)); + } + + // Compute removals: packages in installed but not in locked. Iterate + // installed.json in reverse, mirroring Composer's + // `Transaction::calculateOperations`, which seeds `removeMap` from + // `presentPackages` in order and then `array_unshift`s each entry onto + // `operations` — flipping the iteration order. + let locked_names: IndexSet = locked.iter().map(|p| p.name.to_lowercase()).collect(); + let removals: Vec = installed + .packages + .iter() + .rev() + .filter(|p| !locked_names.contains(&p.name.to_lowercase())) + .map(|p| p.name.clone()) + .collect(); + + (ops, removals) +} + +/// Order a slice of locked packages so every package's `require` deps that +/// are present in the same slice come before it. Mirrors +/// `Composer\DependencyResolver\Transaction::calculateOperations` — the +/// stack-based DFS over the result map. +fn topological_sort<'a>(packages: &[&'a LockedPackage]) -> Vec<&'a LockedPackage> { + use std::collections::BTreeMap; + + // Reverse-alphabetical sort, mirroring `setResultPackageMaps`. + let mut sorted: Vec<&'a LockedPackage> = packages.to_vec(); + sorted.sort_by_key(|p| std::cmp::Reverse(p.name.to_lowercase())); + + // Multimap: name → [packages]. A package contributes itself under its + // own name *and* under every `provide`/`replace` entry. + let mut resolves: BTreeMap> = BTreeMap::new(); + for pkg in &sorted { + let names = std::iter::once(pkg.name.to_lowercase()) + .chain(pkg.provide.keys().map(|s| s.to_lowercase())) + .chain(pkg.replace.keys().map(|s| s.to_lowercase())); + for n in names { + resolves.entry(n).or_default().push(*pkg); + } + } + + // Mirror Composer's `getRootPackages`: walk in sorted order, removing + // each package's required providers from the candidate-roots set. + let mut roots_set: IndexSet = sorted.iter().map(|p| p.name.to_lowercase()).collect(); + for pkg in &sorted { + let pkg_lower = pkg.name.to_lowercase(); + if !roots_set.contains(&pkg_lower) { + continue; + } + for dep in pkg.require.keys() { + let dep_lower = dep.to_lowercase(); + if let Some(matches) = resolves.get(&dep_lower) { + for &m in matches { + let m_lower = m.name.to_lowercase(); + if m_lower != pkg_lower { + roots_set.shift_remove(&m_lower); + } + } + } + } + } + + let mut stack: Vec<&'a LockedPackage> = sorted + .iter() + .filter(|p| roots_set.contains(&p.name.to_lowercase())) + .copied() + .collect(); + + let mut visited: IndexSet = IndexSet::new(); + let mut processed: IndexSet = IndexSet::new(); + let mut ordered: Vec<&'a LockedPackage> = Vec::with_capacity(packages.len()); + + while let Some(pkg) = stack.pop() { + let lower = pkg.name.to_lowercase(); + if processed.contains(&lower) { + continue; + } + if !visited.contains(&lower) { + visited.insert(lower); + stack.push(pkg); + for dep in pkg.require.keys() { + let dep_lower = dep.to_lowercase(); + if let Some(matches) = resolves.get(&dep_lower) { + for &m in matches { + stack.push(m); + } + } + } + } else { + processed.insert(lower); + ordered.push(pkg); + } + } + + // Cycle / disconnected fallback: append any leftover packages. + for pkg in packages { + let lower = pkg.name.to_lowercase(); + if !processed.contains(&lower) { + processed.insert(lower); + ordered.push(*pkg); + } + } + + ordered +} + +/// Pre-rendered MarkAliasUninstalled operation. Caller pre-computes the +/// display strings so the executor call site stays simple. +pub struct StaleInstalledAlias { + pub name: String, + pub alias_full: String, + pub target_full: String, +} + +/// `(package_name_lowercase, alias_pretty)` pairs the *new* lock's packages +/// will surface — used by `compute_stale_installed_aliases` to determine which +/// currently-installed alias packages no longer have a counterpart in the new +/// lock. Mirrors `Locker::getLockedRepository` running every locked package +/// through `ArrayLoader`. +fn lock_alias_pretty_pairs(lock: &LockFile) -> std::collections::HashSet<(String, String)> { + use std::collections::HashSet; + let mut set: HashSet<(String, String)> = HashSet::new(); + for a in &lock.aliases { + set.insert((a.package.to_lowercase(), a.alias.clone())); + } + for pkg in lock + .packages + .iter() + .chain(lock.packages_dev.iter().flatten()) + { + let mut emitted_explicit = false; + if let Some(map) = pkg + .extra_fields + .get("extra") + .and_then(|e| e.get("branch-alias")) + .and_then(|b| b.as_object()) + { + for (source, target) in map { + if !source.eq_ignore_ascii_case(&pkg.version) { + continue; + } + let Some(target_str) = target.as_str() else { + continue; + }; + if !target_str.to_lowercase().ends_with("-dev") { + continue; + } + set.insert((pkg.name.to_lowercase(), target_str.to_string())); + emitted_explicit = true; + } + } + if emitted_explicit { + continue; + } + let is_default_branch = pkg + .extra_fields + .get("default-branch") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !is_default_branch { + continue; + } + let version_lower = pkg.version.to_lowercase(); + let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); + if !is_dev_branch { + continue; + } + set.insert((pkg.name.to_lowercase(), "9999999-dev".to_string())); + } + set +} + +/// Walk every `installed.json` entry, expand its `extra.branch-alias` map, and +/// emit a [`StaleInstalledAlias`] for each whose alias version doesn't appear +/// in the new lock. Mirrors `Transaction::calculateOperations` +/// `MarkAliasUninstalledOperation` logic. +pub fn compute_stale_installed_aliases( + installed: &InstalledPackages, + lock: &LockFile, +) -> Vec { + use super::{ + format_full_pretty_version_for_installed, format_full_pretty_with_pretty_for_installed, + }; + + let preserved = lock_alias_pretty_pairs(lock); + let still_present = |name: &str, alias_pretty: &str| -> bool { + preserved.contains(&(name.to_lowercase(), alias_pretty.to_string())) + }; + let mut stale = Vec::new(); + for entry in &installed.packages { + let mut emitted_explicit = false; + if let Some(branch_alias) = entry + .extra_fields + .get("extra") + .and_then(|e| e.get("branch-alias")) + .and_then(|b| b.as_object()) + { + for (target_branch, alias_value) in branch_alias { + if entry.version != *target_branch { + continue; + } + let Some(alias_pretty) = alias_value.as_str() else { + continue; + }; + emitted_explicit = true; + if still_present(&entry.name, alias_pretty) { + continue; + } + stale.push(StaleInstalledAlias { + name: entry.name.clone(), + alias_full: format_full_pretty_with_pretty_for_installed(alias_pretty, entry), + target_full: format_full_pretty_version_for_installed(entry), + }); + } + } + + // Synthetic `9999999-dev` default-branch alias. + if emitted_explicit { + continue; + } + let is_default_branch = entry + .extra_fields + .get("default-branch") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if !is_default_branch { + continue; + } + let version_lower = entry.version.to_lowercase(); + let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); + if !is_dev_branch { + continue; + } + const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev"; + if still_present(&entry.name, DEFAULT_BRANCH_ALIAS) { + continue; + } + stale.push(StaleInstalledAlias { + name: entry.name.clone(), + alias_full: format_full_pretty_with_pretty_for_installed(DEFAULT_BRANCH_ALIAS, entry), + target_full: format_full_pretty_version_for_installed(entry), + }); + } + stale +} + +/// Collect the alias normalized-versions a previous install recorded for +/// `pkg_name`. Mirrors Composer's `presentAliasMap` seeding. +pub fn previously_installed_alias_versions( + installed: &InstalledPackages, + pkg_name: &str, +) -> Vec { + let mut out = Vec::new(); + for entry in &installed.packages { + if !entry.name.eq_ignore_ascii_case(pkg_name) { + continue; + } + let version_lower = entry.version.to_lowercase(); + let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); + if !is_dev_branch { + continue; + } + + let mut emitted_explicit_alias = false; + if let Some(branch_alias_map) = entry + .extra_fields + .get("extra") + .and_then(|e| e.get("branch-alias")) + .and_then(|b| b.as_object()) + { + for (source, target) in branch_alias_map { + if !source.eq_ignore_ascii_case(&entry.version) { + continue; + } + let Some(target_str) = target.as_str() else { + continue; + }; + if !target_str.to_lowercase().ends_with("-dev") { + continue; + } + if let Some(normalized) = crate::resolver::normalize_branch_alias_target(target_str) + { + out.push(normalized); + emitted_explicit_alias = true; + } + } + } + + if !emitted_explicit_alias + && entry + .extra_fields + .get("default-branch") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + out.push("9999999.9999999.9999999.9999999-dev".to_string()); + } + } + out +} + +/// Convert a `LockedPackage` to an `InstalledPackageEntry`. +/// +/// Mirrors Composer's `InstalledFilesystemRepository::write()` via +/// `ArrayDumper` — `extra_fields` is forwarded verbatim so flags like +/// `abandoned` and `default-branch` survive the lock → installed.json round +/// trip. +pub fn locked_to_installed_entry(pkg: &LockedPackage, _vendor_dir: &Path) -> InstalledPackageEntry { + let install_path = format!("../{}", pkg.name); + InstalledPackageEntry { + name: pkg.name.clone(), + version: pkg.version.clone(), + version_normalized: pkg.version_normalized.clone(), + source: pkg + .source + .as_ref() + .map(|s| serde_json::to_value(s).unwrap_or_default()), + dist: pkg + .dist + .as_ref() + .map(|d| serde_json::to_value(d).unwrap_or_default()), + package_type: pkg.package_type.clone(), + install_path: Some(install_path), + autoload: pkg.autoload.clone(), + aliases: vec![], + homepage: pkg.homepage.clone(), + support: pkg.support.clone(), + extra_fields: pkg.extra_fields.clone(), + } +} + +fn installed_refs_match_locked(entry: &InstalledPackageEntry, locked: &LockedPackage) -> bool { + let installed_source_ref = entry + .source + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let installed_dist_ref = entry + .dist + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let locked_source_ref = locked.source.as_ref().and_then(|s| s.reference.as_deref()); + let locked_dist_ref = locked.dist.as_ref().and_then(|d| d.reference.as_deref()); + installed_source_ref == locked_source_ref && installed_dist_ref == locked_dist_ref +} + +fn abandoned_state(v: Option<&serde_json::Value>) -> (bool, Option<&str>) { + match v { + Some(serde_json::Value::Bool(b)) => (*b, None), + Some(serde_json::Value::String(s)) => (true, Some(s.as_str())), + _ => (false, None), + } +} + +fn installed_abandoned_matches_locked( + entry: &InstalledPackageEntry, + locked: &LockedPackage, +) -> bool { + abandoned_state(entry.extra_fields.get("abandoned")) + == abandoned_state(locked.extra_fields.get("abandoned")) +} diff --git a/crates/mozart/src/commands/show.rs b/crates/mozart/src/commands/show.rs index c675d54..8bd53cb 100644 --- a/crates/mozart/src/commands/show.rs +++ b/crates/mozart/src/commands/show.rs @@ -4,6 +4,8 @@ use mozart_core::console_format; use mozart_core::console_writeln; use mozart_core::console_writeln_error; use mozart_core::matches_wildcard; +use mozart_core::platform::is_platform_package; +use std::collections::BTreeMap; use std::path::Path; #[derive(Args)] @@ -111,37 +113,47 @@ pub async fn execute( let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache); let repo_cache = mozart_registry::cache::Cache::repo(&cache_config); + // A9: --installed deprecation warning (mirrors Composer 143-145) + if args.installed && !args.self_info { + console_writeln_error!( + console, + &console_format!( + "You are using the deprecated option \"installed\". Only installed packages are shown by default now. The --all option can be used to show all packages." + ), + ); + } + // Validate mutually exclusive level filters let level_count = args.major_only as u8 + args.minor_only as u8 + args.patch_only as u8; if level_count > 1 { anyhow::bail!("Only one of --major-only, --minor-only or --patch-only can be used at once"); } - // Fix 1: --direct with --all, --platform, or --available + // --direct with --all, --platform, or --available if args.direct && (args.all || args.platform || args.available) { anyhow::bail!( "The --direct (-D) option is not usable in combination with --all, --platform (-p) or --available (-a)" ); } - // Fix 2: --tree with --all or --available + // --tree with --all or --available if args.tree && (args.all || args.available) { anyhow::bail!( "The --tree (-t) option is not usable in combination with --all or --available (-a)" ); } - // Fix 3: --tree with --latest + // --tree with --latest if args.tree && args.latest { anyhow::bail!("The --tree (-t) option is not usable in combination with --latest (-l)"); } - // Fix 4: --tree with --path + // --tree with --path if args.tree && args.path { anyhow::bail!("The --tree (-t) option is not usable in combination with --path (-P)"); } - // Fix 5: --format with invalid value + // --format validation if let Some(ref fmt) = args.format && fmt != "text" && fmt != "json" @@ -152,12 +164,12 @@ pub async fn execute( ); } - // Fix 6: --self with a package argument + // --self with a package argument if args.self_info && args.package.is_some() { anyhow::bail!("You cannot use --self together with a package name"); } - // Fix 8: --ignore without --outdated warning + // --ignore without --outdated warning if !args.ignore.is_empty() && !args.outdated { console_writeln_error!( console, @@ -174,17 +186,17 @@ pub async fn execute( return show_platform(args, &working_dir, console); } - // --self: show root package info (unless --installed or --locked override) + // --self: show root package info if args.self_info && !args.installed && !args.locked { return show_self(args, &working_dir, console); } - // --tree: show dependency tree (uses lock file) + // --tree: show dependency tree if args.tree { return show_tree(args, &working_dir, console); } - // --available: show available versions for installed packages + // --available: show available versions if args.available { return show_available(args, &working_dir, &repo_cache, console).await; } @@ -198,147 +210,273 @@ pub async fn execute( execute_installed(args, &working_dir, &repo_cache, console).await } -async fn execute_installed( +// ============================================================================ +// Unified types +// ============================================================================ + +/// Mirrors Composer's latest-package data used in list view. +struct LatestInfo { + version: String, + version_normalized: String, + /// None = not abandoned; Some("") = abandoned, no replacement suggested; + /// Some("vendor/pkg") = abandoned, replacement suggested. + abandoned: Option, +} + +/// Unified per-row data for the package list view. +struct PackageEntry { + name: String, + version: String, + version_normalized: String, + description: String, + /// True when this package is a direct root requirement. + is_direct: bool, + /// Release date string from the package metadata (for --sort-by-age). + release_date: Option, + latest_info: Option, +} + +/// Unified data for the single-package detail view. Mirrors Composer's +/// `printPackageInfo` + `printMeta` + `printLinks`. +struct PackageDetail { + name: String, + description: String, + keywords: Vec, + version: String, + package_type: Option, + licenses: Vec, + homepage: Option, + source_type: Option, + source_url: Option, + source_ref: Option, + dist_type: Option, + dist_url: Option, + dist_ref: Option, + install_path: Option, + /// A13: release date ("released" field). + release_date: Option, + /// A13: all names (canonical + provides + replaces). + names: Vec, + /// A13: support links object. + support: Option, + /// A13: autoload rules. + autoload: Option, + require: BTreeMap, + require_dev: BTreeMap, + /// A12: conflict links. + conflict: BTreeMap, + /// A12: provide links. + provide: BTreeMap, + /// A12: replace links. + replace: BTreeMap, + suggest: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ListUpdateKind { + UpToDate, + Compatible, + Incompatible, +} + +// ============================================================================ +// Helper utilities +// ============================================================================ + +/// Compute the set of direct-dependency package names from composer.json. +fn compute_direct_names(working_dir: &Path, no_dev: bool) -> anyhow::Result> { + let composer_json_path = working_dir.join("composer.json"); + if !composer_json_path.exists() { + return Ok(IndexSet::new()); + } + let root = mozart_core::package::read_from_file(&composer_json_path)?; + let mut names: IndexSet = root.require.keys().map(|k| k.to_lowercase()).collect(); + if !no_dev { + names.extend(root.require_dev.keys().map(|k| k.to_lowercase())); + } + Ok(names) +} + +/// Fetch the latest version of a package from Packagist, applying +/// --major-only / --minor-only / --patch-only constraints (A3). +async fn fetch_latest_for_package( + name: &str, + current_normalized: &str, args: &ShowArgs, - working_dir: &Path, repo_cache: &mozart_registry::cache::Cache, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { - let vendor_dir = working_dir.join("vendor"); - let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; +) -> anyhow::Result { + use mozart_core::package::Stability; + use mozart_registry::version::find_best_candidate; - if installed.packages.is_empty() { - // Warn if composer.json has requirements but nothing is installed - let composer_json_path = working_dir.join("composer.json"); - if composer_json_path.exists() { - let root = mozart_core::package::read_from_file(&composer_json_path)?; - if !root.require.is_empty() || !root.require_dev.is_empty() { - console_writeln_error!( - console, - &console_format!( - "No dependencies installed. Try running mozart install or update." - ), - ); - } - } - return Ok(()); + let versions = mozart_registry::packagist::fetch_package_versions(name, repo_cache).await?; + + let current_major = extract_major(current_normalized); + let current_minor = extract_minor(current_normalized); + + // Mirrors Composer ShowCommand::findLatestPackage 1494-1496: + // dev-versioned packages cannot use major-only filtering. + let is_dev = current_normalized.starts_with("dev-") || current_normalized.ends_with("-dev"); + if args.major_only && is_dev { + anyhow::bail!("Cannot determine major update for dev version of {name}"); } - // --path with a specific package name: just show the path for that one package - if args.path - && let Some(ref package_name) = args.package - && !package_name.contains('*') - { - let pkg = installed - .packages - .iter() - .find(|p| p.name.eq_ignore_ascii_case(package_name)); - match pkg { - Some(p) => { - let install_path = vendor_dir.join(&p.name); - let path_str = resolve_path(&install_path); - console_writeln!(console, &format!("{} {}", p.name, path_str),); - } - None => { - anyhow::bail!( - "Package \"{}\" not found, try using --available (-a) to show all available packages", - package_name - ); + let filtered: Vec = versions + .iter() + .filter(|v| { + let v_norm = &v.version_normalized; + let v_major = extract_major(v_norm); + let v_minor = extract_minor(v_norm); + if args.major_only { + v_major > current_major + } else if args.minor_only { + v_major == current_major + } else if args.patch_only { + v_major == current_major && v_minor == current_minor + } else { + true } - } - return Ok(()); - } + }) + .cloned() + .collect(); - // Filter packages - let mut packages = filter_installed_packages(&installed, args, working_dir)?; + let best = find_best_candidate(&filtered, Stability::Stable) + .ok_or_else(|| anyhow::anyhow!("No suitable version found for {name}"))?; - // Apply wildcard or exact package filter - if let Some(ref package_filter) = args.package { - if package_filter.contains('*') { - packages.retain(|p| matches_wildcard(&p.name, package_filter)); - show_installed_package_list(&packages, args, &vendor_dir, repo_cache, console).await?; - return Ok(()); - } else { - // Single package detail view - return show_installed_package_detail(&installed, package_filter, working_dir, console); - } - } + let abandoned = best.abandoned.as_ref().and_then(abandoned_info); - // --path list mode - if args.path { - for pkg in &packages { - let install_path = vendor_dir.join(&pkg.name); - let path_str = resolve_path(&install_path); - console_writeln!(console, &format!("{} {}", pkg.name, path_str),); - } - return Ok(()); - } + Ok(LatestInfo { + version: best.version.clone(), + version_normalized: best.version_normalized.clone(), + abandoned, + }) +} - // List view - show_installed_package_list(&packages, args, &vendor_dir, repo_cache, console).await +/// Extract the abandonment string from a Packagist `abandoned` field value. +/// Returns None if the package is not abandoned. +fn abandoned_info(val: &serde_json::Value) -> Option { + match val { + serde_json::Value::Bool(true) => Some(String::new()), + serde_json::Value::String(s) if !s.is_empty() && s != "false" => Some(s.clone()), + _ => None, + } } -fn filter_installed_packages<'a>( - installed: &'a mozart_registry::installed::InstalledPackages, - args: &ShowArgs, - working_dir: &Path, -) -> anyhow::Result> { - let mut packages: Vec<&mozart_registry::installed::InstalledPackageEntry> = - installed.packages.iter().collect(); +fn classify_update_category(current_normalized: &str, latest_normalized: &str) -> ListUpdateKind { + use mozart_registry::version::compare_normalized_versions; + use std::cmp::Ordering; - // --no-dev: exclude dev packages - if args.no_dev { - let dev_names: IndexSet = installed - .dev_package_names - .iter() - .map(|n| n.to_lowercase()) - .collect(); - packages.retain(|p| !dev_names.contains(&p.name.to_lowercase())); + if compare_normalized_versions(latest_normalized, current_normalized) != Ordering::Greater { + return ListUpdateKind::UpToDate; } - // --direct: only show packages directly required by root - if args.direct { - let composer_json_path = working_dir.join("composer.json"); - if composer_json_path.exists() { - let root = mozart_core::package::read_from_file(&composer_json_path)?; - let mut direct_names: IndexSet = - root.require.keys().map(|k| k.to_lowercase()).collect(); - if !args.no_dev { - direct_names.extend(root.require_dev.keys().map(|k| k.to_lowercase())); - } - packages.retain(|p| direct_names.contains(&p.name.to_lowercase())); - } + let current_major = extract_major(current_normalized); + let latest_major = extract_major(latest_normalized); + if current_major == latest_major { + ListUpdateKind::Compatible + } else { + ListUpdateKind::Incompatible } +} - // Sort alphabetically by name - packages.sort_by_key(|a| a.name.to_lowercase()); +fn extract_major(version_normalized: &str) -> u64 { + let base = if let Some(pos) = version_normalized.find('-') { + &version_normalized[..pos] + } else { + version_normalized + }; + base.split('.') + .next() + .and_then(|p| p.parse().ok()) + .unwrap_or(0) +} - Ok(packages) +fn extract_minor(version_normalized: &str) -> u64 { + let base = if let Some(pos) = version_normalized.find('-') { + &version_normalized[..pos] + } else { + version_normalized + }; + base.split('.') + .nth(1) + .and_then(|p| p.parse().ok()) + .unwrap_or(0) } -async fn show_installed_package_list( +// ============================================================================ +// List entry collection +// ============================================================================ + +async fn collect_installed_entries( packages: &[&mozart_registry::installed::InstalledPackageEntry], args: &ShowArgs, - _vendor_dir: &Path, + direct_names: &IndexSet, repo_cache: &mozart_registry::cache::Cache, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { - // --latest / --outdated: fetch latest versions from Packagist +) -> Vec { let show_latest = args.latest || args.outdated; + let mut entries = Vec::new(); - if args.name_only { - for pkg in packages { - console_writeln!(console, &pkg.name); + for pkg in packages { + if args + .ignore + .iter() + .any(|pattern| matches_wildcard(&pkg.name, pattern)) + { + continue; } - return Ok(()); - } - if packages.is_empty() { - return Ok(()); + let version_normalized = pkg + .version_normalized + .clone() + .unwrap_or_else(|| normalize_version_simple(&pkg.version)); + let description = get_installed_description(pkg); + let is_direct = direct_names.contains(&pkg.name.to_lowercase()); + let release_date = get_installed_release_date(pkg); + + let latest_info = if show_latest { + fetch_latest_for_package(&pkg.name, &version_normalized, args, repo_cache) + .await + .ok() + } else { + None + }; + + if args.outdated { + if let Some(ref li) = latest_info { + use mozart_registry::version::compare_normalized_versions; + use std::cmp::Ordering; + if compare_normalized_versions(&li.version_normalized, &version_normalized) + != Ordering::Greater + { + continue; + } + } else { + continue; + } + } + + entries.push(PackageEntry { + name: pkg.name.clone(), + version: pkg.version.clone(), + version_normalized, + description, + is_direct, + release_date, + latest_info, + }); } - // Gather entries (fetch latest if needed, apply outdated filter) - let mut entries: Vec = Vec::new(); + entries +} + +async fn collect_locked_entries( + packages: &[&mozart_registry::lockfile::LockedPackage], + args: &ShowArgs, + direct_names: &IndexSet, + repo_cache: &mozart_registry::cache::Cache, +) -> Vec { + let show_latest = args.latest || args.outdated; + let mut entries = Vec::new(); + for pkg in packages { if args .ignore @@ -352,15 +490,18 @@ async fn show_installed_package_list( .version_normalized .clone() .unwrap_or_else(|| normalize_version_simple(&pkg.version)); - let description = get_installed_description(pkg); + let description = pkg.description.as_deref().unwrap_or("").to_string(); + let is_direct = direct_names.contains(&pkg.name.to_lowercase()); + let release_date = pkg.time.clone(); let latest_info = if show_latest { - fetch_latest_for_package(&pkg.name, repo_cache).await.ok() + fetch_latest_for_package(&pkg.name, &version_normalized, args, repo_cache) + .await + .ok() } else { None }; - // --outdated: skip packages that are up-to-date if args.outdated { if let Some(ref li) = latest_info { use mozart_registry::version::compare_normalized_versions; @@ -371,58 +512,122 @@ async fn show_installed_package_list( continue; } } else { - // Cannot determine latest: skip continue; } } - entries.push(InstalledListEntry { + entries.push(PackageEntry { name: pkg.name.clone(), version: pkg.version.clone(), version_normalized, description, + is_direct, + release_date, latest_info, }); } - // --strict: exit 1 if any outdated - let has_outdated = entries.iter().any(|e| e.latest_info.is_some()); + entries +} - // JSON output +// ============================================================================ +// List rendering (unified) +// ============================================================================ + +/// Render the package list view. Returns true if any package is outdated +/// (for --strict handling). Mirrors Composer's list-view block (398–710). +fn render_package_list( + entries: &mut [PackageEntry], + args: &ShowArgs, + section_key: &str, + console: &mozart_core::console::Console, +) -> anyhow::Result { + let show_latest = args.latest || args.outdated; let format = args.format.as_deref().unwrap_or("text"); + + // A4: --sort-by-age (mirrors Composer 497-504) + if args.sort_by_age { + entries.sort_by(|a, b| a.release_date.cmp(&b.release_date)); + } + + let has_outdated = entries.iter().any(|e| e.latest_info.is_some()); + if format == "json" { - render_installed_json(&entries, console)?; - if args.strict && has_outdated { - return Err(mozart_core::exit_code::bail_silent( - mozart_core::exit_code::GENERAL_ERROR, - )); - } - return Ok(()); + render_list_json(entries, section_key, console)?; + return Ok(has_outdated); } - // Text output - let name_width = entries.iter().map(|e| e.name.len()).max().unwrap_or(0); - let version_width = entries - .iter() - .map(|e| format_version(&e.version).len()) - .max() - .unwrap_or(0); - let latest_width = if show_latest { - entries - .iter() - .map(|e| { - e.latest_info - .as_ref() - .map(|li| format_version(&li.version).len()) - .unwrap_or(0) - }) + // A6: Color legend (mirrors Composer 626-642) + if show_latest && !entries.is_empty() { + print_color_legend(console); + } + + // A7: Direct/Transitive split (mirrors Composer 671-695) + // Only applies when --latest is on and --direct is not set. + if show_latest && !args.direct { + let direct_entries: Vec<&PackageEntry> = entries.iter().filter(|e| e.is_direct).collect(); + let transitive_entries: Vec<&PackageEntry> = + entries.iter().filter(|e| !e.is_direct).collect(); + + console_writeln!( + console, + &console_format!("Direct dependencies required in composer.json:"), + ); + if direct_entries.is_empty() { + console_writeln!(console, "Everything up to date"); + } else { + print_package_rows(&direct_entries, args, console); + } + + console_writeln!(console, ""); + console_writeln!( + console, + &console_format!("Transitive dependencies not required in composer.json:"), + ); + if transitive_entries.is_empty() { + console_writeln!(console, "Everything up to date"); + } else { + print_package_rows(&transitive_entries, args, console); + } + } else { + let all_refs: Vec<&PackageEntry> = entries.iter().collect(); + print_package_rows(&all_refs, args, console); + } + + Ok(has_outdated) +} + +/// Print a row for each entry. Applies A5 (abandoned warning) and A6 +/// (ASCII prefix markers in non-decorated mode). +fn print_package_rows( + entries: &[&PackageEntry], + args: &ShowArgs, + console: &mozart_core::console::Console, +) { + let show_latest = args.latest || args.outdated; + + let name_width = entries.iter().map(|e| e.name.len()).max().unwrap_or(0); + let version_width = entries + .iter() + .map(|e| format_version(&e.version).len()) + .max() + .unwrap_or(0); + let latest_width = if show_latest { + entries + .iter() + .map(|e| { + e.latest_info + .as_ref() + .map(|li| format_version(&li.version).len()) + .unwrap_or(0) + }) .max() .unwrap_or(0) } else { 0 }; - for entry in &entries { + for entry in entries { let version = format_version(&entry.version); let category = entry .latest_info @@ -455,6 +660,18 @@ async fn show_installed_package_list( width = version_width ); + // A6: ASCII prefix markers for non-decorated terminals (Composer 736/1438) + let ascii_prefix = if !console.decorated && show_latest { + match category { + Some(ListUpdateKind::Compatible) => "! ", + Some(ListUpdateKind::Incompatible) => "~ ", + Some(ListUpdateKind::UpToDate) => "= ", + None => "", + } + } else { + "" + }; + if show_latest { let latest_str = match entry.latest_info.as_ref() { Some(li) => { @@ -484,97 +701,79 @@ async fn show_installed_package_list( console_writeln!( console, &format!( - "{} {} {} {}", - name_str, version_str, latest_str, entry.description + "{}{} {} {} {}", + ascii_prefix, name_str, version_str, latest_str, entry.description ), ); } else { console_writeln!( console, - &format!("{} {} {}", name_str, version_str, entry.description), + &format!( + "{}{} {} {}", + ascii_prefix, name_str, version_str, entry.description + ), ); } - } - if args.strict && has_outdated { - return Err(mozart_core::exit_code::bail_silent( - mozart_core::exit_code::GENERAL_ERROR, - )); + // A5: Abandoned warning (mirrors Composer printPackages 778-780) + if let Some(ref li) = entry.latest_info + && let Some(ref replacement) = li.abandoned + { + let msg = if replacement.is_empty() { + format!( + "Package {} is abandoned, you should avoid using it. No replacement was suggested.", + entry.name + ) + } else { + format!( + "Package {} is abandoned, you should avoid using it. Use {} instead.", + entry.name, replacement + ) + }; + console_writeln_error!(console, &console_format!("{}", msg),); + } } - - Ok(()) } -/// Entry for the installed package list (with optional latest info) -struct InstalledListEntry { - name: String, - version: String, - version_normalized: String, - description: String, - latest_info: Option, -} - -struct LatestInfo { - version: String, - version_normalized: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ListUpdateKind { - UpToDate, - Compatible, - Incompatible, -} - -fn classify_update_category(current_normalized: &str, latest_normalized: &str) -> ListUpdateKind { - use mozart_registry::version::compare_normalized_versions; - use std::cmp::Ordering; - - if compare_normalized_versions(latest_normalized, current_normalized) != Ordering::Greater { - return ListUpdateKind::UpToDate; - } - - // Compare major versions to determine compatibility - let current_major = extract_major(current_normalized); - let latest_major = extract_major(latest_normalized); - if current_major == latest_major { - ListUpdateKind::Compatible +/// Print the color legend before the list (A6, mirrors Composer 626-642). +fn print_color_legend(console: &mozart_core::console::Console) { + if console.decorated { + console_writeln!(console, &console_format!("Color legend:"),); + console_writeln!( + console, + &format!( + "- {} release available - update recommended", + console_format!("patch or minor") + ), + ); + console_writeln!( + console, + &format!( + "- {} release available - update possible", + console_format!("major") + ), + ); + console_writeln!( + console, + &format!("- {} version", console_format!("up to date")), + ); } else { - ListUpdateKind::Incompatible + console_writeln!(console, "Legend:"); + console_writeln!( + console, + "! patch or minor release available - update recommended", + ); + console_writeln!(console, "~ major release available - update possible"); + console_writeln!(console, "= up to date version"); } + console_writeln!(console, ""); } -fn extract_major(version_normalized: &str) -> u64 { - let base = if let Some(pos) = version_normalized.find('-') { - &version_normalized[..pos] - } else { - version_normalized - }; - base.split('.') - .next() - .and_then(|p| p.parse().ok()) - .unwrap_or(0) -} - -async fn fetch_latest_for_package( - name: &str, - repo_cache: &mozart_registry::cache::Cache, -) -> anyhow::Result { - use mozart_core::package::Stability; - use mozart_registry::version::find_best_candidate; - - let versions = mozart_registry::packagist::fetch_package_versions(name, repo_cache).await?; - let best = find_best_candidate(&versions, Stability::Stable) - .ok_or_else(|| anyhow::anyhow!("No stable version found for {name}"))?; - - Ok(LatestInfo { - version: best.version.clone(), - version_normalized: best.version_normalized.clone(), - }) -} - -fn render_installed_json( - entries: &[InstalledListEntry], +/// Emit the JSON list output. Uses `section_key` as the top-level key +/// (A14: "installed" vs "locked" vs "platform" etc.). +fn render_list_json( + entries: &[PackageEntry], + section_key: &str, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let json_entries: Vec = entries @@ -601,45 +800,159 @@ fn render_installed_json( }) .collect(); - let output = serde_json::json!({ "installed": json_entries }); + let output = serde_json::json!({ section_key: json_entries }); console_writeln!(console, &serde_json::to_string_pretty(&output)?,); Ok(()) } -fn show_installed_package_detail( - installed: &mozart_registry::installed::InstalledPackages, - package_name: &str, - working_dir: &Path, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { - // Find the package (case-insensitive) - let pkg = installed - .packages - .iter() - .find(|p| p.name.eq_ignore_ascii_case(package_name)); - - let pkg = match pkg { - Some(p) => p, - None => { - anyhow::bail!( - "Package \"{}\" not found, try using --available (-a) to show all available packages", - package_name - ); - } +// ============================================================================ +// Detail view (unified — A15) +// ============================================================================ + +/// Build a `PackageDetail` from an installed package entry. +fn installed_to_detail( + pkg: &mozart_registry::installed::InstalledPackageEntry, + vendor_dir: &Path, +) -> PackageDetail { + let install_path = vendor_dir.join(&pkg.name); + let path_str = if install_path.exists() { + Some(install_path.display().to_string()) + } else { + None }; - let vendor_dir = working_dir.join("vendor"); + let (source_type, source_url, source_ref) = match &pkg.source { + Some(src) => ( + src.get("type").and_then(|v| v.as_str()).map(str::to_string), + src.get("url").and_then(|v| v.as_str()).map(str::to_string), + src.get("reference") + .and_then(|v| v.as_str()) + .map(str::to_string), + ), + None => (None, None, None), + }; + + let (dist_type, dist_url, dist_ref) = match &pkg.dist { + Some(d) => ( + d.get("type").and_then(|v| v.as_str()).map(str::to_string), + d.get("url").and_then(|v| v.as_str()).map(str::to_string), + d.get("reference") + .and_then(|v| v.as_str()) + .map(str::to_string), + ), + None => (None, None, None), + }; + + let provide = get_installed_link_map(pkg, "provide"); + let replace = get_installed_link_map(pkg, "replace"); + + let mut names = vec![pkg.name.clone()]; + names.extend(provide.keys().cloned()); + names.extend(replace.keys().cloned()); + + PackageDetail { + name: pkg.name.clone(), + description: get_installed_description(pkg), + keywords: get_installed_keywords_vec(pkg), + version: pkg.version.clone(), + package_type: pkg.package_type.clone(), + licenses: get_installed_licenses(pkg), + homepage: get_installed_homepage(pkg), + source_type, + source_url, + source_ref, + dist_type, + dist_url, + dist_ref, + install_path: path_str, + release_date: get_installed_release_date(pkg), + names, + support: pkg.extra_fields.get("support").cloned(), + autoload: pkg.autoload.clone(), + require: get_installed_link_map(pkg, "require"), + require_dev: get_installed_link_map(pkg, "require-dev"), + conflict: get_installed_link_map(pkg, "conflict"), + provide, + replace, + suggest: get_installed_suggest_map(pkg), + } +} + +/// Build a `PackageDetail` from a locked package entry. +fn locked_to_detail(pkg: &mozart_registry::lockfile::LockedPackage) -> PackageDetail { + let mut names = vec![pkg.name.clone()]; + names.extend(pkg.provide.keys().cloned()); + names.extend(pkg.replace.keys().cloned()); + + let (source_type, source_url, source_ref) = match &pkg.source { + Some(src) => ( + Some(src.source_type.clone()), + Some(src.url.clone()), + src.reference.clone(), + ), + None => (None, None, None), + }; + + let (dist_type, dist_url, dist_ref) = match &pkg.dist { + Some(d) => ( + Some(d.dist_type.clone()), + Some(d.url.clone()), + d.reference.clone(), + ), + None => (None, None, None), + }; + + PackageDetail { + name: pkg.name.clone(), + description: pkg.description.as_deref().unwrap_or("").to_string(), + keywords: pkg.keywords.as_deref().unwrap_or(&[]).to_vec(), + version: pkg.version.clone(), + package_type: pkg.package_type.clone(), + licenses: pkg.license.as_deref().unwrap_or(&[]).to_vec(), + homepage: pkg.homepage.clone(), + source_type, + source_url, + source_ref, + dist_type, + dist_url, + dist_ref, + install_path: None, + release_date: pkg.time.clone(), + names, + support: pkg.support.clone(), + autoload: pkg.autoload.clone(), + require: pkg.require.clone(), + require_dev: pkg.require_dev.clone(), + conflict: pkg.conflict.clone(), + provide: pkg.provide.clone(), + replace: pkg.replace.clone(), + suggest: pkg.suggest.as_ref().cloned().unwrap_or_default(), + } +} + +/// Print single-package detail view. Mirrors Composer's `printPackageInfo` + +/// `printMeta` + `printLinks`. Shared by installed and locked paths (A15). +async fn print_package_detail( + detail: &PackageDetail, + args: &ShowArgs, + repo_cache: &mozart_registry::cache::Cache, + console: &mozart_core::console::Console, +) -> anyhow::Result<()> { + let format = args.format.as_deref().unwrap_or("text"); + if format == "json" { + return print_package_detail_json(detail, args, repo_cache, console).await; + } console_writeln!( console, - &format!("{} : {}", console_format!("name"), pkg.name), + &format!("{} : {}", console_format!("name"), detail.name), ); console_writeln!( console, &format!( "{} : {}", console_format!("descrip."), - get_installed_description(pkg) + detail.description ), ); console_writeln!( @@ -647,7 +960,7 @@ fn show_installed_package_detail( &format!( "{} : {}", console_format!("keywords"), - get_installed_keywords(pkg) + detail.keywords.join(", ") ), ); console_writeln!( @@ -655,32 +968,68 @@ fn show_installed_package_detail( &format!( "{} : {}", console_format!("versions"), - format_version_highlight(&pkg.version) + format_version_highlight(&detail.version) ), ); + + // A13: released + if let Some(ref date) = detail.release_date { + console_writeln!( + console, + &format!("{} : {}", console_format!("released"), date), + ); + } + + // A11: latest (when --latest is on) + if args.latest || args.outdated { + let version_normalized = normalize_version_simple(&detail.version); + if let Ok(li) = + fetch_latest_for_package(&detail.name, &version_normalized, args, repo_cache).await + { + let update_kind = classify_update_category(&version_normalized, &li.version_normalized); + let latest_str = match update_kind { + ListUpdateKind::Compatible => { + console_format!("{}", &li.version) + } + ListUpdateKind::Incompatible => { + console_format!("{}", &li.version) + } + ListUpdateKind::UpToDate => { + console_format!("{}", &li.version) + } + }; + console_writeln!( + console, + &format!( + "{} : {}", + console_format!("latest"), + latest_str + ), + ); + } + } + console_writeln!( console, &format!( "{} : {}", console_format!("type"), - pkg.package_type.as_deref().unwrap_or("library") + detail.package_type.as_deref().unwrap_or("library") ), ); - // License — one line per identifier, matching Composer's printLicenses. - for license_id in get_installed_licenses(pkg) { + for license_id in &detail.licenses { console_writeln!( console, &format!( "{} : {}", console_format!("license"), - format_license_for_show(&license_id), + format_license_for_show(license_id), ), ); } - // Homepage - if let Some(homepage) = get_installed_homepage(pkg) { + if let Some(ref homepage) = detail.homepage { console_writeln!( console, &format!( @@ -691,31 +1040,24 @@ fn show_installed_package_detail( ); } - // Source - if let Some(source) = &pkg.source { - let source_type = source.get("type").and_then(|v| v.as_str()).unwrap_or(""); - let source_url = source.get("url").and_then(|v| v.as_str()).unwrap_or(""); - let source_ref = source - .get("reference") - .and_then(|v| v.as_str()) - .unwrap_or(""); + if let Some(ref src_url) = detail.source_url { + let src_type = detail.source_type.as_deref().unwrap_or(""); + let src_ref = detail.source_ref.as_deref().unwrap_or(""); console_writeln!( console, &format!( "{} : [{}] {} {}", console_format!("source"), - source_type, - console_format!("{}", source_url), - source_ref + src_type, + console_format!("{}", src_url), + src_ref ), ); } - // Dist - if let Some(dist) = &pkg.dist { - let dist_type = dist.get("type").and_then(|v| v.as_str()).unwrap_or(""); - let dist_url = dist.get("url").and_then(|v| v.as_str()).unwrap_or(""); - let dist_ref = dist.get("reference").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(ref dist_url) = detail.dist_url { + let dist_type = detail.dist_type.as_deref().unwrap_or(""); + let dist_ref = detail.dist_ref.as_deref().unwrap_or(""); console_writeln!( console, &format!( @@ -728,121 +1070,278 @@ fn show_installed_package_detail( ); } - // Path - let install_path = vendor_dir.join(&pkg.name); - if install_path.exists() { + if let Some(ref path) = detail.install_path { + console_writeln!( + console, + &format!("{} : {}", console_format!("path"), path), + ); + } + + // A13: names (when multiple) + if detail.names.len() > 1 { console_writeln!( console, &format!( "{} : {}", - console_format!("path"), - install_path.display() + console_format!("names"), + detail.names.join(", ") ), ); } - // Requires - if let Some(requires) = pkg.extra_fields.get("require").and_then(|v| v.as_object()) - && !requires.is_empty() + // A13: support + if let Some(ref support) = detail.support + && let Some(obj) = support.as_object() + && !obj.is_empty() { console_writeln!(console, ""); - console_writeln!(console, &console_format!("requires"),); - for (name, constraint) in requires { - let c = constraint.as_str().unwrap_or(""); + console_writeln!(console, &console_format!("support"),); + for (key, val) in obj { + let v = val.as_str().unwrap_or(""); console_writeln!( console, - &format!("{} {}", name, console_format!("{}", c)), + &format!("{} {}", key, console_format!("{}", v)), ); } } - // Requires (dev) - if let Some(requires_dev) = pkg - .extra_fields - .get("require-dev") - .and_then(|v| v.as_object()) - && !requires_dev.is_empty() - { + // A13: autoload + if let Some(ref autoload) = detail.autoload { console_writeln!(console, ""); - console_writeln!(console, &console_format!("requires (dev)"),); - for (name, constraint) in requires_dev { - let c = constraint.as_str().unwrap_or(""); - console_writeln!( - console, - &format!("{} {}", name, console_format!("{}", c)), - ); + console_writeln!(console, &console_format!("autoload"),); + if let Some(obj) = autoload.as_object() { + for (loader_type, config) in obj { + match config { + serde_json::Value::Object(map) => { + for (k, v) in map { + let v_str = v.as_str().unwrap_or(""); + console_writeln!( + console, + &format!( + "{}: {} => {}", + loader_type, + k, + console_format!("{}", v_str) + ), + ); + } + } + serde_json::Value::Array(arr) => { + for item in arr { + let v_str = item.as_str().unwrap_or(""); + console_writeln!( + console, + &format!( + "{}: {}", + loader_type, + console_format!("{}", v_str) + ), + ); + } + } + _ => {} + } + } } } + // Links: requires, requires-dev, conflict, provide, replace, suggests (A12) + print_links_section("requires", &detail.require, console); + print_links_section("requires (dev)", &detail.require_dev, console); + print_links_section("conflict", &detail.conflict, console); + print_links_section("provide", &detail.provide, console); + print_links_section("replace", &detail.replace, console); + print_links_section("suggests", &detail.suggest, console); + Ok(()) } -async fn execute_locked( +/// Print a named section of package links (requires, conflict, etc.). +fn print_links_section( + label: &str, + links: &BTreeMap, + console: &mozart_core::console::Console, +) { + if links.is_empty() { + return; + } + console_writeln!(console, ""); + console_writeln!(console, &console_format!("{}", label),); + for (name, constraint) in links { + console_writeln!( + console, + &format!( + "{} {}", + name, + console_format!("{}", constraint) + ), + ); + } +} + +/// JSON output for single-package detail (mirrors Composer's +/// `printPackageInfoAsJson`). +async fn print_package_detail_json( + detail: &PackageDetail, args: &ShowArgs, - working_dir: &Path, repo_cache: &mozart_registry::cache::Cache, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let lock_path = working_dir.join("composer.lock"); - if !lock_path.exists() { - anyhow::bail!( - "A valid composer.json and composer.lock files is required to run this command with --locked" - ); + let mut obj = serde_json::json!({ + "name": detail.name, + "description": detail.description, + "keywords": detail.keywords, + "type": detail.package_type.as_deref().unwrap_or("library"), + "homepage": detail.homepage, + "license": detail.licenses, + "versions": [format_version_highlight(&detail.version)], + }); + + if !detail.require.is_empty() { + obj["require"] = serde_json::json!(detail.require); + } + if !detail.require_dev.is_empty() { + obj["require-dev"] = serde_json::json!(detail.require_dev); + } + if !detail.conflict.is_empty() { + obj["conflict"] = serde_json::json!(detail.conflict); + } + if !detail.provide.is_empty() { + obj["provide"] = serde_json::json!(detail.provide); + } + if !detail.replace.is_empty() { + obj["replace"] = serde_json::json!(detail.replace); + } + if !detail.suggest.is_empty() { + obj["suggest"] = serde_json::json!(detail.suggest); + } + if let Some(ref date) = detail.release_date { + obj["time"] = serde_json::Value::String(date.clone()); + } + if let Some(ref support) = detail.support { + obj["support"] = support.clone(); + } + if let Some(ref autoload) = detail.autoload { + obj["autoload"] = autoload.clone(); } - let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; + // A11: latest when --latest/--outdated + if args.latest || args.outdated { + let version_normalized = normalize_version_simple(&detail.version); + if let Ok(li) = + fetch_latest_for_package(&detail.name, &version_normalized, args, repo_cache).await + { + obj["latest"] = serde_json::Value::String(li.version.clone()); + let status = classify_update_category(&version_normalized, &li.version_normalized); + obj["latest-status"] = serde_json::Value::String(match status { + ListUpdateKind::UpToDate => "up-to-date".to_string(), + ListUpdateKind::Compatible => "semver-safe-update".to_string(), + ListUpdateKind::Incompatible => "update-possible".to_string(), + }); + } + } - // Combine packages and packages-dev - let mut packages: Vec<&mozart_registry::lockfile::LockedPackage> = - lock.packages.iter().collect(); + console_writeln!(console, &serde_json::to_string_pretty(&obj)?,); + Ok(()) +} - if let Some(ref pkgs_dev) = lock.packages_dev - && !args.no_dev - { - packages.extend(pkgs_dev.iter()); - } +// ============================================================================ +// Installed mode +// ============================================================================ - // --direct filter - if args.direct { +async fn execute_installed( + args: &ShowArgs, + working_dir: &Path, + repo_cache: &mozart_registry::cache::Cache, + console: &mozart_core::console::Console, +) -> anyhow::Result<()> { + let vendor_dir = working_dir.join("vendor"); + let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; + + if installed.packages.is_empty() { let composer_json_path = working_dir.join("composer.json"); if composer_json_path.exists() { let root = mozart_core::package::read_from_file(&composer_json_path)?; - let mut direct_names: IndexSet = - root.require.keys().map(|k| k.to_lowercase()).collect(); - if !args.no_dev { - direct_names.extend(root.require_dev.keys().map(|k| k.to_lowercase())); + if !root.require.is_empty() || !root.require_dev.is_empty() { + console_writeln_error!( + console, + &console_format!( + "No dependencies installed. Try running mozart install or update." + ), + ); } - packages.retain(|p| direct_names.contains(&p.name.to_lowercase())); } + return Ok(()); } - // Sort alphabetically - packages.sort_by_key(|a| a.name.to_lowercase()); + // --path with a specific package name: show path and exit + if args.path + && let Some(ref package_name) = args.package + && !package_name.contains('*') + { + let pkg = installed + .packages + .iter() + .find(|p| p.name.eq_ignore_ascii_case(package_name)); + match pkg { + Some(p) => { + let install_path = vendor_dir.join(&p.name); + let path_str = resolve_path(&install_path); + console_writeln!(console, &format!("{} {}", p.name, path_str),); + } + None => { + anyhow::bail!( + "Package \"{}\" not found, try using --available (-a) to show all available packages", + package_name + ); + } + } + return Ok(()); + } + + let direct_names = compute_direct_names(working_dir, args.no_dev)?; + // Filter packages (--no-dev, --direct) + let mut packages = filter_installed_packages(&installed, args, &direct_names); + + // Apply wildcard or exact package filter if let Some(ref package_filter) = args.package { if package_filter.contains('*') { packages.retain(|p| matches_wildcard(&p.name, package_filter)); - show_locked_package_list(&packages, args, repo_cache, console).await?; } else { - show_locked_package_detail(&lock, package_filter, console)?; + // Single package detail view + let pkg = installed + .packages + .iter() + .find(|p| p.name.eq_ignore_ascii_case(package_filter)); + let pkg = match pkg { + Some(p) => p, + None => { + anyhow::bail!( + "Package \"{}\" not found, try using --available (-a) to show all available packages", + package_filter + ); + } + }; + let detail = installed_to_detail(pkg, &vendor_dir); + return print_package_detail(&detail, args, repo_cache, console).await; } - } else { - show_locked_package_list(&packages, args, repo_cache, console).await?; } - Ok(()) -} + // --path list mode + if args.path { + for pkg in &packages { + let install_path = vendor_dir.join(&pkg.name); + let path_str = resolve_path(&install_path); + console_writeln!(console, &format!("{} {}", pkg.name, path_str),); + } + return Ok(()); + } -async fn show_locked_package_list( - packages: &[&mozart_registry::lockfile::LockedPackage], - args: &ShowArgs, - repo_cache: &mozart_registry::cache::Cache, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { + // --name-only let show_latest = args.latest || args.outdated; - - if args.name_only { - for pkg in packages { + if args.name_only && !show_latest { + for pkg in &packages { console_writeln!(console, &pkg.name); } return Ok(()); @@ -852,384 +1351,157 @@ async fn show_locked_package_list( return Ok(()); } - // Gather entries - let mut entries: Vec = Vec::new(); - for pkg in packages { - if args - .ignore - .iter() - .any(|pattern| matches_wildcard(&pkg.name, pattern)) - { - continue; - } + let mut entries = collect_installed_entries(&packages, args, &direct_names, repo_cache).await; - let version_normalized = pkg - .version_normalized - .clone() - .unwrap_or_else(|| normalize_version_simple(&pkg.version)); - let description = pkg.description.as_deref().unwrap_or("").to_string(); - - let latest_info = if show_latest { - fetch_latest_for_package(&pkg.name, repo_cache).await.ok() - } else { - None - }; - - // --outdated: skip packages that are up-to-date - if args.outdated { - if let Some(ref li) = latest_info { - use mozart_registry::version::compare_normalized_versions; - use std::cmp::Ordering; - if compare_normalized_versions(&li.version_normalized, &version_normalized) - != Ordering::Greater - { - continue; - } - } else { - continue; - } - } - - entries.push(LockedListEntry { - name: pkg.name.clone(), - version: pkg.version.clone(), - version_normalized, - description, - latest_info, - }); - } - - let has_outdated = entries.iter().any(|e| e.latest_info.is_some()); - - // JSON format - let format = args.format.as_deref().unwrap_or("text"); - if format == "json" { - render_locked_json(&entries, console)?; - if args.strict && has_outdated { - return Err(mozart_core::exit_code::bail_silent( - mozart_core::exit_code::GENERAL_ERROR, - )); + if args.name_only { + for e in &entries { + console_writeln!(console, &e.name); } return Ok(()); } - // Text format - let name_width = entries.iter().map(|e| e.name.len()).max().unwrap_or(0); - let version_width = entries - .iter() - .map(|e| format_version(&e.version).len()) - .max() - .unwrap_or(0); - let latest_width = if show_latest { - entries - .iter() - .map(|e| { - e.latest_info - .as_ref() - .map(|li| format_version(&li.version).len()) - .unwrap_or(0) - }) - .max() - .unwrap_or(0) - } else { - 0 - }; - - for entry in &entries { - let version = format_version(&entry.version); - let category = entry - .latest_info - .as_ref() - .map(|li| classify_update_category(&entry.version_normalized, &li.version_normalized)); + // A10: --strict exit code + let has_outdated = render_package_list(&mut entries, args, "installed", console)?; + if args.strict && has_outdated { + return Err(mozart_core::exit_code::bail_silent( + mozart_core::exit_code::GENERAL_ERROR, + )); + } - let name_str = match category { - Some(ListUpdateKind::Compatible) => { - console_format!( - "{:", - entry.name, - width = name_width - ) - } - Some(ListUpdateKind::Incompatible) => { - console_format!( - "{:", - entry.name, - width = name_width - ) - } - _ => { - console_format!("{:", entry.name, width = name_width) - } - }; + Ok(()) +} - let version_str = console_format!( - "{:", - version, - width = version_width - ); +fn filter_installed_packages<'a>( + installed: &'a mozart_registry::installed::InstalledPackages, + args: &ShowArgs, + direct_names: &IndexSet, +) -> Vec<&'a mozart_registry::installed::InstalledPackageEntry> { + let mut packages: Vec<&mozart_registry::installed::InstalledPackageEntry> = + installed.packages.iter().collect(); - if show_latest { - let latest_str = match entry.latest_info.as_ref() { - Some(li) => { - let lv = format_version(&li.version); - match category { - Some(ListUpdateKind::Compatible) => { - console_format!( - "{:", - lv, - width = latest_width - ) - } - Some(ListUpdateKind::Incompatible) => { - console_format!( - "{:", - lv, - width = latest_width - ) - } - _ => { - console_format!("{:", lv, width = latest_width) - } - } - } - None => format!("{: = installed + .dev_package_names + .iter() + .map(|n| n.to_lowercase()) + .collect(); + packages.retain(|p| !dev_names.contains(&p.name.to_lowercase())); } - if args.strict && has_outdated { - return Err(mozart_core::exit_code::bail_silent( - mozart_core::exit_code::GENERAL_ERROR, - )); + // --direct: only show packages directly required by root + if args.direct { + packages.retain(|p| direct_names.contains(&p.name.to_lowercase())); } - Ok(()) + packages.sort_by_key(|a| a.name.to_lowercase()); + packages } -struct LockedListEntry { - name: String, - version: String, - version_normalized: String, - description: String, - latest_info: Option, -} +// ============================================================================ +// Locked mode +// ============================================================================ -fn render_locked_json( - entries: &[LockedListEntry], +async fn execute_locked( + args: &ShowArgs, + working_dir: &Path, + repo_cache: &mozart_registry::cache::Cache, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - let json_entries: Vec = entries - .iter() - .map(|entry| { - let mut obj = serde_json::json!({ - "name": entry.name, - "version": entry.version, - "description": entry.description, - }); - if let Some(ref li) = entry.latest_info { - obj["latest"] = serde_json::Value::String(li.version.clone()); - let status = - if classify_update_category(&entry.version_normalized, &li.version_normalized) - == ListUpdateKind::UpToDate - { - "up-to-date" - } else { - "outdated" - }; - obj["latest-status"] = serde_json::Value::String(status.to_string()); - } - obj - }) - .collect(); + let lock_path = working_dir.join("composer.lock"); + if !lock_path.exists() { + anyhow::bail!( + "A valid composer.json and composer.lock files is required to run this command with --locked" + ); + } - let output = serde_json::json!({ "installed": json_entries }); - console_writeln!(console, &serde_json::to_string_pretty(&output)?,); - Ok(()) -} + let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; -fn show_locked_package_detail( - lock: &mozart_registry::lockfile::LockFile, - package_name: &str, - console: &mozart_core::console::Console, -) -> anyhow::Result<()> { - // Search in both packages and packages-dev - let pkg = lock - .packages - .iter() - .chain(lock.packages_dev.iter().flatten()) - .find(|p| p.name.eq_ignore_ascii_case(package_name)); + let mut packages: Vec<&mozart_registry::lockfile::LockedPackage> = + lock.packages.iter().collect(); - let pkg = match pkg { - Some(p) => p, - None => { - anyhow::bail!("Package \"{}\" not found in lock file", package_name); - } - }; + if let Some(ref pkgs_dev) = lock.packages_dev + && !args.no_dev + { + packages.extend(pkgs_dev.iter()); + } - console_writeln!( - console, - &format!("{} : {}", console_format!("name"), pkg.name), - ); - console_writeln!( - console, - &format!( - "{} : {}", - console_format!("descrip."), - pkg.description.as_deref().unwrap_or("") - ), - ); + let direct_names = compute_direct_names(working_dir, args.no_dev)?; - // Keywords - let keywords = pkg - .keywords - .as_ref() - .map(|kw| kw.join(", ")) - .unwrap_or_default(); - console_writeln!( - console, - &format!( - "{} : {}", - console_format!("keywords"), - keywords - ), - ); + // --direct filter + if args.direct { + packages.retain(|p| direct_names.contains(&p.name.to_lowercase())); + } - console_writeln!( - console, - &format!( - "{} : * {}", - console_format!("versions"), - format_version(&pkg.version) - ), - ); - console_writeln!( - console, - &format!( - "{} : {}", - console_format!("type"), - pkg.package_type.as_deref().unwrap_or("library") - ), - ); + packages.sort_by_key(|a| a.name.to_lowercase()); - // License — one line per identifier, matching Composer's printLicenses. - if let Some(ref licenses) = pkg.license { - for license_id in licenses { - console_writeln!( - console, - &format!( - "{} : {}", - console_format!("license"), - format_license_for_show(license_id), - ), - ); + if let Some(ref package_filter) = args.package { + if package_filter.contains('*') { + packages.retain(|p| matches_wildcard(&p.name, package_filter)); + } else { + // Single package detail view + let pkg = lock + .packages + .iter() + .chain(lock.packages_dev.iter().flatten()) + .find(|p| p.name.eq_ignore_ascii_case(package_filter)); + let pkg = match pkg { + Some(p) => p, + None => { + anyhow::bail!("Package \"{}\" not found in lock file", package_filter); + } + }; + let detail = locked_to_detail(pkg); + return print_package_detail(&detail, args, repo_cache, console).await; } } - // Homepage - if let Some(ref homepage) = pkg.homepage { - console_writeln!( + // --path list mode + if args.path { + console_writeln_error!( console, - &format!( - "{} : {}", - console_format!("homepage"), - homepage - ), + &console_format!("--path is not supported with --locked"), ); + return Ok(()); } - // Source - if let Some(ref source) = pkg.source { - console_writeln!( - console, - &format!( - "{} : [{}] {} {}", - console_format!("source"), - source.source_type, - console_format!("{}", &source.url), - source.reference.as_deref().unwrap_or("") - ), - ); + // --name-only + let show_latest = args.latest || args.outdated; + if args.name_only && !show_latest { + for pkg in &packages { + console_writeln!(console, &pkg.name); + } + return Ok(()); } - // Dist - if let Some(ref dist) = pkg.dist { - console_writeln!( - console, - &format!( - "{} : [{}] {} {}", - console_format!("dist"), - dist.dist_type, - console_format!("{}", &dist.url), - dist.reference.as_deref().unwrap_or("") - ), - ); + if packages.is_empty() { + return Ok(()); } - // Requires - if !pkg.require.is_empty() { - console_writeln!(console, ""); - console_writeln!(console, &console_format!("requires"),); - for (name, constraint) in &pkg.require { - console_writeln!( - console, - &format!( - "{} {}", - name, - console_format!("{}", constraint) - ), - ); - } - } + let mut entries = collect_locked_entries(&packages, args, &direct_names, repo_cache).await; - // Requires (dev) - if !pkg.require_dev.is_empty() { - console_writeln!(console, ""); - console_writeln!(console, &console_format!("requires (dev)"),); - for (name, constraint) in &pkg.require_dev { - console_writeln!( - console, - &format!( - "{} {}", - name, - console_format!("{}", constraint) - ), - ); + if args.name_only { + for e in &entries { + console_writeln!(console, &e.name); } + return Ok(()); } - // Suggests - if let Some(ref suggests) = pkg.suggest - && !suggests.is_empty() - { - console_writeln!(console, ""); - console_writeln!(console, &console_format!("suggests"),); - for (name, reason) in suggests { - console_writeln!( - console, - &format!( - "{} {}", - name, - console_format!("{}", reason) - ), - ); - } + // A10: --strict exit code; A14: use "locked" as the JSON key + let has_outdated = render_package_list(&mut entries, args, "locked", console)?; + if args.strict && has_outdated { + return Err(mozart_core::exit_code::bail_silent( + mozart_core::exit_code::GENERAL_ERROR, + )); } Ok(()) } +// ============================================================================ +// Self mode +// ============================================================================ + fn show_self( args: &ShowArgs, working_dir: &Path, @@ -1322,6 +1594,10 @@ fn show_self( Ok(()) } +// ============================================================================ +// Tree mode +// ============================================================================ + fn show_tree( args: &ShowArgs, working_dir: &Path, @@ -1336,7 +1612,6 @@ fn show_tree( let root = mozart_core::package::read_from_file(&composer_json_path)?; - // Load all locked packages into a map for quick lookup let pkg_map: IndexMap; let lock_storage; if lock_path.exists() { @@ -1351,12 +1626,9 @@ fn show_tree( pkg_map = IndexMap::new(); } - // Determine roots to display: package filter or full tree let root_reqs: Vec<(String, String)> = if let Some(ref pkg_filter) = args.package { - // If a specific package is requested, show its sub-tree vec![(pkg_filter.clone(), "*".to_string())] } else { - // Show from root composer.json let mut reqs: Vec<(String, String)> = root .require .iter() @@ -1369,7 +1641,6 @@ fn show_tree( reqs }; - // Print root console_writeln!( console, &console_format!( @@ -1379,7 +1650,6 @@ fn show_tree( ), ); - // Render each root dependency as a tree let mut visited_global: IndexSet = IndexSet::new(); let count = root_reqs.len(); for (i, (dep_name, dep_constraint)) in root_reqs.iter().enumerate() { @@ -1417,7 +1687,6 @@ fn print_tree_node( let key = pkg_name.to_lowercase(); - // Look up the package in the lock file if let Some(pkg) = pkg_map.get(&key) { let description = pkg.description.as_deref().unwrap_or(""); let version = format_version(&pkg.version); @@ -1432,7 +1701,6 @@ fn print_tree_node( ), ); - // Detect circular dependency or depth limit if visited.contains(&key) || depth >= MAX_DEPTH { if visited.contains(&key) { console_writeln!( @@ -1445,12 +1713,10 @@ fn print_tree_node( visited.insert(key.clone()); - // Print children (require only, not require-dev for transitive) let children: Vec<(&String, &String)> = pkg.require.iter().collect(); let child_count = children.len(); for (ci, (child_name, child_constraint)) in children.iter().enumerate() { let child_key = child_name.to_lowercase(); - // Skip platform packages if is_platform_package(&child_key) { continue; } @@ -1484,7 +1750,6 @@ fn print_tree_node( visited.shift_remove(&key); } else { - // Package not found in lock file (platform package or not installed) if !is_platform_package(&key) { console_writeln!( console, @@ -1499,36 +1764,23 @@ fn print_tree_node( } } -fn is_platform_package(name: &str) -> bool { - let lower = name.to_lowercase(); - lower == "php" - || lower.starts_with("ext-") - || lower.starts_with("lib-") - || lower == "php-64bit" - || lower == "php-ipv6" - || lower == "php-zts" - || lower == "php-debug" - || lower == "composer-plugin-api" - || lower == "composer-runtime-api" -} +// ============================================================================ +// Platform mode +// ============================================================================ fn show_platform( args: &ShowArgs, working_dir: &Path, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - // Collect platform info from lock file and system detection - let mut platform_packages: Vec<(String, String, String)> = Vec::new(); // (name, version, source) + let mut platform_packages: Vec<(String, String, String)> = Vec::new(); - // Try to detect PHP from the system let php_version = mozart_core::platform::detect_php_version(); - // Load platform requirements from lock file if available let lock_path = working_dir.join("composer.lock"); if lock_path.exists() { let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; - // Collect platform entries from lock's platform field if let Some(obj) = lock.platform.as_object() { for (name, version_val) in obj { let version_str = version_val.as_str().unwrap_or("*").to_string(); @@ -1540,7 +1792,6 @@ fn show_platform( { for (name, version_val) in obj { let version_str = version_val.as_str().unwrap_or("*").to_string(); - // Only add if not already present if !platform_packages.iter().any(|(n, _, _)| n == name) { platform_packages.push((name.clone(), version_str, "lock-dev".to_string())); } @@ -1548,14 +1799,12 @@ fn show_platform( } } - // Add detected PHP version if available and not already listed if let Some(ref ver) = php_version && !platform_packages.iter().any(|(n, _, _)| n == "php") { platform_packages.push(("php".to_string(), ver.clone(), "detected".to_string())); } - // Detect PHP extensions if PHP is available let extensions = mozart_core::platform::detect_php_extensions(); for ext in &extensions { let ext_name = format!("ext-{ext}"); @@ -1564,10 +1813,8 @@ fn show_platform( } } - // Sort platform_packages.sort_by(|a, b| a.0.cmp(&b.0)); - // Determine format let format = args.format.as_deref().unwrap_or("text"); if format == "json" { let json_entries: Vec = platform_packages @@ -1630,26 +1877,26 @@ fn show_platform( Ok(()) } +// ============================================================================ +// Available mode +// ============================================================================ + async fn show_available( args: &ShowArgs, working_dir: &Path, repo_cache: &mozart_registry::cache::Cache, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { - // If a specific package name is given, show available versions for it if let Some(ref pkg_name) = args.package { return show_available_versions(pkg_name, repo_cache, args, console).await; } - // Otherwise, show all installed packages with their available (latest) versions - // by querying Packagist for each installed package let vendor_dir = working_dir.join("vendor"); let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir); let installed = match installed { Ok(i) if !i.packages.is_empty() => i, _ => { - // Try lock file let lock_path = working_dir.join("composer.lock"); if lock_path.exists() { let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; @@ -1791,7 +2038,6 @@ async fn show_available_versions_inline( ); return; } - // Show up to 5 most recent versions let shown: Vec<&str> = versions .iter() .take(5) @@ -1824,17 +2070,18 @@ async fn show_available_versions_inline( } } -/// Format version string for display: strip leading 'v' for text output. +// ============================================================================ +// String / field extraction helpers +// ============================================================================ + fn format_version(version: &str) -> String { version.strip_prefix('v').unwrap_or(version).to_string() } -/// Format version with highlight for the detail view (asterisk prefix). fn format_version_highlight(version: &str) -> String { format!("* {}", format_version(version)) } -/// Extract description from an InstalledPackageEntry's extra_fields. fn get_installed_description(pkg: &mozart_registry::installed::InstalledPackageEntry) -> String { pkg.extra_fields .get("description") @@ -1843,21 +2090,20 @@ fn get_installed_description(pkg: &mozart_registry::installed::InstalledPackageE .to_string() } -/// Extract keywords from an InstalledPackageEntry's extra_fields. -fn get_installed_keywords(pkg: &mozart_registry::installed::InstalledPackageEntry) -> String { +fn get_installed_keywords_vec( + pkg: &mozart_registry::installed::InstalledPackageEntry, +) -> Vec { pkg.extra_fields .get("keywords") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() - .filter_map(|v| v.as_str()) - .collect::>() - .join(", ") + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() }) .unwrap_or_default() } -/// Extract license identifiers from an InstalledPackageEntry's extra_fields. fn get_installed_licenses(pkg: &mozart_registry::installed::InstalledPackageEntry) -> Vec { pkg.extra_fields .get("license") @@ -1870,6 +2116,56 @@ fn get_installed_licenses(pkg: &mozart_registry::installed::InstalledPackageEntr .unwrap_or_default() } +fn get_installed_homepage( + pkg: &mozart_registry::installed::InstalledPackageEntry, +) -> Option { + pkg.extra_fields + .get("homepage") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn get_installed_release_date( + pkg: &mozart_registry::installed::InstalledPackageEntry, +) -> Option { + pkg.extra_fields + .get("time") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +/// Extract a map of `{name: constraint}` from an installed package's +/// extra_fields for the given key (e.g. "require", "conflict", "provide"). +fn get_installed_link_map( + pkg: &mozart_registry::installed::InstalledPackageEntry, + key: &str, +) -> BTreeMap { + pkg.extra_fields + .get(key) + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() +} + +/// Extract a map of `{package: reason}` from an installed package's suggest field. +fn get_installed_suggest_map( + pkg: &mozart_registry::installed::InstalledPackageEntry, +) -> BTreeMap { + pkg.extra_fields + .get("suggest") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() +} + /// Format a single license identifier for the `show` text output. Mirrors /// Composer's `Command\ShowCommand::printLicenses()`: /// * unknown id → just the id @@ -1888,17 +2184,6 @@ fn format_license_for_show(license_id: &str) -> String { } } -/// Extract homepage from an InstalledPackageEntry's extra_fields. -fn get_installed_homepage( - pkg: &mozart_registry::installed::InstalledPackageEntry, -) -> Option { - pkg.extra_fields - .get("homepage") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) -} - -/// Resolve a path to its canonical form, falling back to the display form. fn resolve_path(path: &Path) -> String { if path.exists() { path.canonicalize() @@ -1910,7 +2195,6 @@ fn resolve_path(path: &Path) -> String { } } -/// Simple version normalizer fallback when `version_normalized` is absent. fn normalize_version_simple(version: &str) -> String { let v = version.strip_prefix('v').unwrap_or(version); let (base, suffix) = if let Some(pos) = v.find('-') { @@ -1930,6 +2214,10 @@ fn normalize_version_simple(version: &str) -> String { result } +// ============================================================================ +// Tests +// ============================================================================ + #[cfg(test)] mod tests { use super::*; @@ -1949,7 +2237,6 @@ mod tests { #[test] fn test_format_license_for_show_non_osi() { - // CC-BY-4.0 is in the SPDX list but is not OSI-approved. let out = format_license_for_show("CC-BY-4.0"); assert!( out.contains("(CC-BY-4.0)") && !out.contains("(OSI approved)"), @@ -1968,8 +2255,6 @@ mod tests { #[test] fn test_format_license_for_show_url_uses_canonical_id_casing() { - // Lookup is case-insensitive, but the URL uses the canonical id casing - // from the SPDX database — matching SpdxLicenses::getLicenseByIdentifier. let out = format_license_for_show("mit"); assert!( out.contains("https://spdx.org/licenses/MIT.html#licenseText"), @@ -2034,7 +2319,6 @@ mod tests { #[test] fn test_matches_wildcard_trailing_chars_fail() { - // pattern "psr/l" does not end with * so "psr/log" should not match assert!(!matches_wildcard("psr/log", "psr/l")); } @@ -2111,7 +2395,10 @@ mod tests { support: None, extra_fields: extra, }; - assert_eq!(get_installed_keywords(&pkg), "log, psr3, logging"); + assert_eq!( + get_installed_keywords_vec(&pkg).join(", "), + "log, psr3, logging" + ); } #[test] @@ -2185,4 +2472,39 @@ mod tests { fn test_extract_major_with_prerelease() { assert_eq!(extract_major("2.3.4.0-beta1"), 2); } + + #[test] + fn test_extract_minor_basic() { + assert_eq!(extract_minor("2.3.4.0"), 3); + assert_eq!(extract_minor("1.0.0.0"), 0); + } + + #[test] + fn test_extract_minor_with_prerelease() { + assert_eq!(extract_minor("2.3.4.0-rc1"), 3); + } + + #[test] + fn test_abandoned_info_bool_true() { + let val = serde_json::Value::Bool(true); + assert_eq!(abandoned_info(&val), Some(String::new())); + } + + #[test] + fn test_abandoned_info_string_replacement() { + let val = serde_json::Value::String("other/package".to_string()); + assert_eq!(abandoned_info(&val), Some("other/package".to_string())); + } + + #[test] + fn test_abandoned_info_false() { + let val = serde_json::Value::Bool(false); + assert_eq!(abandoned_info(&val), None); + } + + #[test] + fn test_abandoned_info_string_false() { + let val = serde_json::Value::String("false".to_string()); + assert_eq!(abandoned_info(&val), None); + } } -- cgit v1.3.1