aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart/src')
-rw-r--r--crates/mozart/src/commands/show.rs1764
1 files changed, 1043 insertions, 721 deletions
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!(
+ "<warning>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.</warning>"
+ ),
+ );
+ }
+
// 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<String>,
+}
+
+/// 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<String>,
+ latest_info: Option<LatestInfo>,
+}
+
+/// Unified data for the single-package detail view. Mirrors Composer's
+/// `printPackageInfo` + `printMeta` + `printLinks`.
+struct PackageDetail {
+ name: String,
+ description: String,
+ keywords: Vec<String>,
+ version: String,
+ package_type: Option<String>,
+ licenses: Vec<String>,
+ homepage: Option<String>,
+ source_type: Option<String>,
+ source_url: Option<String>,
+ source_ref: Option<String>,
+ dist_type: Option<String>,
+ dist_url: Option<String>,
+ dist_ref: Option<String>,
+ install_path: Option<String>,
+ /// A13: release date ("released" field).
+ release_date: Option<String>,
+ /// A13: all names (canonical + provides + replaces).
+ names: Vec<String>,
+ /// A13: support links object.
+ support: Option<serde_json::Value>,
+ /// A13: autoload rules.
+ autoload: Option<serde_json::Value>,
+ require: BTreeMap<String, String>,
+ require_dev: BTreeMap<String, String>,
+ /// A12: conflict links.
+ conflict: BTreeMap<String, String>,
+ /// A12: provide links.
+ provide: BTreeMap<String, String>,
+ /// A12: replace links.
+ replace: BTreeMap<String, String>,
+ suggest: BTreeMap<String, String>,
+}
+
+#[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<IndexSet<String>> {
+ 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<String> = 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<LatestInfo> {
+ 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!(
- "<warning>No dependencies installed. Try running mozart install or update.</warning>"
- ),
- );
- }
- }
- 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<mozart_registry::packagist::PackagistVersion> = 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<String> {
+ 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<Vec<&'a mozart_registry::installed::InstalledPackageEntry>> {
- 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<String> = 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<String> =
- 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<String>,
repo_cache: &mozart_registry::cache::Cache,
- console: &mozart_core::console::Console,
-) -> anyhow::Result<()> {
- // --latest / --outdated: fetch latest versions from Packagist
+) -> Vec<PackageEntry> {
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<InstalledListEntry> = Vec::new();
+ entries
+}
+
+async fn collect_locked_entries(
+ packages: &[&mozart_registry::lockfile::LockedPackage],
+ args: &ShowArgs,
+ direct_names: &IndexSet<String>,
+ repo_cache: &mozart_registry::cache::Cache,
+) -> Vec<PackageEntry> {
+ 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,36 +512,100 @@ 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<bool> {
+ 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,
- ));
+ render_list_json(entries, section_key, console)?;
+ return Ok(has_outdated);
+ }
+
+ // 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!("<info>Direct dependencies required in composer.json:</info>"),
+ );
+ if direct_entries.is_empty() {
+ console_writeln!(console, "Everything up to date");
+ } else {
+ print_package_rows(&direct_entries, args, console);
}
- return Ok(());
+
+ console_writeln!(console, "");
+ console_writeln!(
+ console,
+ &console_format!("<info>Transitive dependencies not required in composer.json:</info>"),
+ );
+ 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);
}
- // Text output
+ 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()
@@ -422,7 +627,7 @@ async fn show_installed_package_list(
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!("<warning>{}</warning>", 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<LatestInfo>,
}
-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!("<info>Color legend:</info>"),);
+ console_writeln!(
+ console,
+ &format!(
+ "- {} release available - update recommended",
+ console_format!("<highlight>patch or minor</highlight>")
+ ),
+ );
+ console_writeln!(
+ console,
+ &format!(
+ "- {} release available - update possible",
+ console_format!("<comment>major</comment>")
+ ),
+ );
+ console_writeln!(
+ console,
+ &format!("- {} version", console_format!("<info>up to date</info>")),
+ );
} 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<LatestInfo> {
- 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<serde_json::Value> = 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));
+// ============================================================================
+// Detail view (unified — A15)
+// ============================================================================
- let pkg = match pkg {
- Some(p) => p,
- None => {
- anyhow::bail!(
- "Package \"{}\" not found, try using --available (-a) to show all available packages",
- package_name
- );
- }
+/// 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!("<info>name</info>"), pkg.name),
+ &format!("{} : {}", console_format!("<info>name</info>"), detail.name),
);
console_writeln!(
console,
&format!(
"{} : {}",
console_format!("<info>descrip.</info>"),
- get_installed_description(pkg)
+ detail.description
),
);
console_writeln!(
@@ -647,7 +960,7 @@ fn show_installed_package_detail(
&format!(
"{} : {}",
console_format!("<info>keywords</info>"),
- get_installed_keywords(pkg)
+ detail.keywords.join(", ")
),
);
console_writeln!(
@@ -655,32 +968,68 @@ fn show_installed_package_detail(
&format!(
"{} : {}",
console_format!("<info>versions</info>"),
- 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!("<info>released</info>"), 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!("<highlight>{}</highlight>", &li.version)
+ }
+ ListUpdateKind::Incompatible => {
+ console_format!("<comment>{}</comment>", &li.version)
+ }
+ ListUpdateKind::UpToDate => {
+ console_format!("<info>{}</info>", &li.version)
+ }
+ };
+ console_writeln!(
+ console,
+ &format!(
+ "{} : {}",
+ console_format!("<info>latest</info>"),
+ latest_str
+ ),
+ );
+ }
+ }
+
console_writeln!(
console,
&format!(
"{} : {}",
console_format!("<info>type</info>"),
- 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!("<info>license</info>"),
- 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!("<info>source</info>"),
- source_type,
- console_format!("<comment>{}</comment>", source_url),
- source_ref
+ src_type,
+ console_format!("<comment>{}</comment>", 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,287 +1070,298 @@ 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!("<info>path</info>"), path),
+ );
+ }
+
+ // A13: names (when multiple)
+ if detail.names.len() > 1 {
console_writeln!(
console,
&format!(
"{} : {}",
- console_format!("<info>path</info>"),
- install_path.display()
+ console_format!("<info>names</info>"),
+ 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!("<info>requires</info>"),);
- for (name, constraint) in requires {
- let c = constraint.as_str().unwrap_or("");
+ console_writeln!(console, &console_format!("<info>support</info>"),);
+ for (key, val) in obj {
+ let v = val.as_str().unwrap_or("");
console_writeln!(
console,
- &format!("{} {}", name, console_format!("<comment>{}</comment>", c)),
+ &format!("{} {}", key, console_format!("<comment>{}</comment>", 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!("<info>requires (dev)</info>"),);
- for (name, constraint) in requires_dev {
- let c = constraint.as_str().unwrap_or("");
- console_writeln!(
- console,
- &format!("{} {}", name, console_format!("<comment>{}</comment>", c)),
- );
+ console_writeln!(console, &console_format!("<info>autoload</info>"),);
+ 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!("<comment>{}</comment>", 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!("<comment>{}</comment>", 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(
- args: &ShowArgs,
- working_dir: &Path,
- repo_cache: &mozart_registry::cache::Cache,
+/// Print a named section of package links (requires, conflict, etc.).
+fn print_links_section(
+ label: &str,
+ links: &BTreeMap<String, String>,
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"
+) {
+ if links.is_empty() {
+ return;
+ }
+ console_writeln!(console, "");
+ console_writeln!(console, &console_format!("<info>{}</info>", label),);
+ for (name, constraint) in links {
+ console_writeln!(
+ console,
+ &format!(
+ "{} {}",
+ name,
+ console_format!("<comment>{}</comment>", constraint)
+ ),
);
}
+}
- let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?;
-
- // Combine packages and packages-dev
- let mut packages: Vec<&mozart_registry::lockfile::LockedPackage> =
- lock.packages.iter().collect();
+/// JSON output for single-package detail (mirrors Composer's
+/// `printPackageInfoAsJson`).
+async fn print_package_detail_json(
+ detail: &PackageDetail,
+ args: &ShowArgs,
+ repo_cache: &mozart_registry::cache::Cache,
+ console: &mozart_core::console::Console,
+) -> anyhow::Result<()> {
+ 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 let Some(ref pkgs_dev) = lock.packages_dev
- && !args.no_dev
- {
- packages.extend(pkgs_dev.iter());
+ if !detail.require.is_empty() {
+ obj["require"] = serde_json::json!(detail.require);
}
-
- // --direct filter
- 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<String> =
- 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()));
- }
+ 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();
}
- // Sort alphabetically
- packages.sort_by_key(|a| a.name.to_lowercase());
-
- 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)?;
+ // 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(),
+ });
}
- } else {
- show_locked_package_list(&packages, args, repo_cache, console).await?;
}
+ console_writeln!(console, &serde_json::to_string_pretty(&obj)?,);
Ok(())
}
-async fn show_locked_package_list(
- packages: &[&mozart_registry::lockfile::LockedPackage],
+// ============================================================================
+// Installed mode
+// ============================================================================
+
+async fn execute_installed(
args: &ShowArgs,
+ working_dir: &Path,
repo_cache: &mozart_registry::cache::Cache,
console: &mozart_core::console::Console,
) -> anyhow::Result<()> {
- let show_latest = args.latest || args.outdated;
+ let vendor_dir = working_dir.join("vendor");
+ let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?;
- if args.name_only {
- for pkg in packages {
- console_writeln!(console, &pkg.name);
+ 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)?;
+ if !root.require.is_empty() || !root.require_dev.is_empty() {
+ console_writeln_error!(
+ console,
+ &console_format!(
+ "<warning>No dependencies installed. Try running mozart install or update.</warning>"
+ ),
+ );
+ }
}
return Ok(());
}
- if packages.is_empty() {
+ // --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(());
}
- // Gather entries
- let mut entries: Vec<LockedListEntry> = Vec::new();
- for pkg in packages {
- if args
- .ignore
- .iter()
- .any(|pattern| matches_wildcard(&pkg.name, pattern))
- {
- continue;
- }
+ let direct_names = compute_direct_names(working_dir, args.no_dev)?;
- 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();
+ // Filter packages (--no-dev, --direct)
+ let mut packages = filter_installed_packages(&installed, args, &direct_names);
- let latest_info = if show_latest {
- fetch_latest_for_package(&pkg.name, repo_cache).await.ok()
+ // 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));
} 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;
+ // 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
+ );
}
- } else {
- continue;
- }
+ };
+ let detail = installed_to_detail(pkg, &vendor_dir);
+ return print_package_detail(&detail, args, repo_cache, console).await;
}
-
- 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,
- ));
+ // --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(());
}
- // 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));
+ // --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(());
+ }
- let name_str = match category {
- Some(ListUpdateKind::Compatible) => {
- console_format!(
- "<highlight>{:<width$}</highlight>",
- entry.name,
- width = name_width
- )
- }
- Some(ListUpdateKind::Incompatible) => {
- console_format!(
- "<comment>{:<width$}</comment>",
- entry.name,
- width = name_width
- )
- }
- _ => {
- console_format!("<info>{:<width$}</info>", entry.name, width = name_width)
- }
- };
+ if packages.is_empty() {
+ return Ok(());
+ }
- let version_str = console_format!(
- "<comment>{:<width$}</comment>",
- version,
- width = version_width
- );
+ let mut entries = collect_installed_entries(&packages, args, &direct_names, repo_cache).await;
- 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!(
- "<highlight>{:<width$}</highlight>",
- lv,
- width = latest_width
- )
- }
- Some(ListUpdateKind::Incompatible) => {
- console_format!(
- "<comment>{:<width$}</comment>",
- lv,
- width = latest_width
- )
- }
- _ => {
- console_format!("<info>{:<width$}</info>", lv, width = latest_width)
- }
- }
- }
- None => format!("{:<width$}", "", width = latest_width),
- };
- console_writeln!(
- console,
- &format!(
- "{} {} {} {}",
- name_str, version_str, latest_str, entry.description
- ),
- );
- } else {
- console_writeln!(
- console,
- &format!("{} {} {}", name_str, version_str, entry.description),
- );
+ if args.name_only {
+ for e in &entries {
+ console_writeln!(console, &e.name);
}
+ return Ok(());
}
+ // 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,
@@ -1018,218 +1371,137 @@ async fn show_locked_package_list(
Ok(())
}
-struct LockedListEntry {
- name: String,
- version: String,
- version_normalized: String,
- description: String,
- latest_info: Option<LatestInfo>,
-}
+fn filter_installed_packages<'a>(
+ installed: &'a mozart_registry::installed::InstalledPackages,
+ args: &ShowArgs,
+ direct_names: &IndexSet<String>,
+) -> Vec<&'a mozart_registry::installed::InstalledPackageEntry> {
+ let mut packages: Vec<&mozart_registry::installed::InstalledPackageEntry> =
+ installed.packages.iter().collect();
-fn render_locked_json(
- entries: &[LockedListEntry],
- console: &mozart_core::console::Console,
-) -> anyhow::Result<()> {
- let json_entries: Vec<serde_json::Value> = 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();
+ // --no-dev: exclude dev packages
+ if args.no_dev {
+ let dev_names: IndexSet<String> = installed
+ .dev_package_names
+ .iter()
+ .map(|n| n.to_lowercase())
+ .collect();
+ packages.retain(|p| !dev_names.contains(&p.name.to_lowercase()));
+ }
- let output = serde_json::json!({ "installed": json_entries });
- console_writeln!(console, &serde_json::to_string_pretty(&output)?,);
- Ok(())
+ // --direct: only show packages directly required by root
+ if args.direct {
+ packages.retain(|p| direct_names.contains(&p.name.to_lowercase()));
+ }
+
+ packages.sort_by_key(|a| a.name.to_lowercase());
+ packages
}
-fn show_locked_package_detail(
- lock: &mozart_registry::lockfile::LockFile,
- package_name: &str,
+// ============================================================================
+// Locked mode
+// ============================================================================
+
+async fn execute_locked(
+ args: &ShowArgs,
+ working_dir: &Path,
+ repo_cache: &mozart_registry::cache::Cache,
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 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 pkg = match pkg {
- Some(p) => p,
- None => {
- anyhow::bail!("Package \"{}\" not found in lock file", package_name);
- }
- };
+ let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?;
- console_writeln!(
- console,
- &format!("{} : {}", console_format!("<info>name</info>"), pkg.name),
- );
- console_writeln!(
- console,
- &format!(
- "{} : {}",
- console_format!("<info>descrip.</info>"),
- pkg.description.as_deref().unwrap_or("")
- ),
- );
+ let mut packages: Vec<&mozart_registry::lockfile::LockedPackage> =
+ lock.packages.iter().collect();
- // Keywords
- let keywords = pkg
- .keywords
- .as_ref()
- .map(|kw| kw.join(", "))
- .unwrap_or_default();
- console_writeln!(
- console,
- &format!(
- "{} : {}",
- console_format!("<info>keywords</info>"),
- keywords
- ),
- );
+ if let Some(ref pkgs_dev) = lock.packages_dev
+ && !args.no_dev
+ {
+ packages.extend(pkgs_dev.iter());
+ }
- console_writeln!(
- console,
- &format!(
- "{} : * {}",
- console_format!("<info>versions</info>"),
- format_version(&pkg.version)
- ),
- );
- console_writeln!(
- console,
- &format!(
- "{} : {}",
- console_format!("<info>type</info>"),
- pkg.package_type.as_deref().unwrap_or("library")
- ),
- );
+ let direct_names = compute_direct_names(working_dir, args.no_dev)?;
- // 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!("<info>license</info>"),
- format_license_for_show(license_id),
- ),
- );
- }
+ // --direct filter
+ if args.direct {
+ packages.retain(|p| direct_names.contains(&p.name.to_lowercase()));
}
- // Homepage
- if let Some(ref homepage) = pkg.homepage {
- console_writeln!(
- console,
- &format!(
- "{} : {}",
- console_format!("<info>homepage</info>"),
- homepage
- ),
- );
- }
+ packages.sort_by_key(|a| a.name.to_lowercase());
- // Source
- if let Some(ref source) = pkg.source {
- console_writeln!(
- console,
- &format!(
- "{} : [{}] {} {}",
- console_format!("<info>source</info>"),
- source.source_type,
- console_format!("<comment>{}</comment>", &source.url),
- source.reference.as_deref().unwrap_or("")
- ),
- );
+ 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;
+ }
}
- // Dist
- if let Some(ref dist) = pkg.dist {
- console_writeln!(
+ // --path list mode
+ if args.path {
+ console_writeln_error!(
console,
- &format!(
- "{} : [{}] {} {}",
- console_format!("<info>dist</info>"),
- dist.dist_type,
- console_format!("<comment>{}</comment>", &dist.url),
- dist.reference.as_deref().unwrap_or("")
- ),
+ &console_format!("<warning>--path is not supported with --locked</warning>"),
);
+ return Ok(());
}
- // Requires
- if !pkg.require.is_empty() {
- console_writeln!(console, "");
- console_writeln!(console, &console_format!("<info>requires</info>"),);
- for (name, constraint) in &pkg.require {
- console_writeln!(
- console,
- &format!(
- "{} {}",
- name,
- console_format!("<comment>{}</comment>", constraint)
- ),
- );
+ // --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(());
}
- // Requires (dev)
- if !pkg.require_dev.is_empty() {
- console_writeln!(console, "");
- console_writeln!(console, &console_format!("<info>requires (dev)</info>"),);
- for (name, constraint) in &pkg.require_dev {
- console_writeln!(
- console,
- &format!(
- "{} {}",
- name,
- console_format!("<comment>{}</comment>", constraint)
- ),
- );
- }
+ if packages.is_empty() {
+ return Ok(());
}
- // Suggests
- if let Some(ref suggests) = pkg.suggest
- && !suggests.is_empty()
- {
- console_writeln!(console, "");
- console_writeln!(console, &console_format!("<info>suggests</info>"),);
- for (name, reason) in suggests {
- console_writeln!(
- console,
- &format!(
- "{} {}",
- name,
- console_format!("<comment>{}</comment>", reason)
- ),
- );
+ let mut entries = collect_locked_entries(&packages, args, &direct_names, repo_cache).await;
+
+ if args.name_only {
+ for e in &entries {
+ console_writeln!(console, &e.name);
}
+ return Ok(());
+ }
+
+ // 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<String, &mozart_registry::lockfile::LockedPackage>;
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<String> = 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<serde_json::Value> = 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<String> {
pkg.extra_fields
.get("keywords")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
- .filter_map(|v| v.as_str())
- .collect::<Vec<_>>()
- .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<String> {
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<String> {
+ 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<String> {
+ 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<String, String> {
+ 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<String, String> {
+ 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<String> {
- 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);
+ }
}