aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--crates/mozart/src/commands/show.rs1060
1 files changed, 1016 insertions, 44 deletions
diff --git a/crates/mozart/src/commands/show.rs b/crates/mozart/src/commands/show.rs
index 6a58253..ff157e0 100644
--- a/crates/mozart/src/commands/show.rs
+++ b/crates/mozart/src/commands/show.rs
@@ -1,5 +1,5 @@
use clap::Args;
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Args)]
@@ -100,34 +100,31 @@ pub struct ShowArgs {
}
pub fn execute(args: &ShowArgs, cli: &super::Cli) -> anyhow::Result<()> {
- // Handle unsupported options first
- if args.tree {
- eprintln!("The --tree option is not yet implemented");
- return Ok(());
- }
- if args.available {
- eprintln!("The --available option is not yet implemented");
- return Ok(());
- }
- if args.platform {
- eprintln!("The --platform option is not yet implemented");
- return Ok(());
- }
- if args.outdated {
- eprintln!("The --outdated option is not yet implemented. See `mozart outdated`.");
- return Ok(());
- }
-
let working_dir = match &cli.working_dir {
Some(dir) => PathBuf::from(dir),
None => std::env::current_dir()?,
};
+ // --platform: show detected platform packages
+ if args.platform {
+ return show_platform(args, &working_dir);
+ }
+
// --self: show root package info (unless --installed or --locked override)
if args.self_info && !args.installed && !args.locked {
return show_self(args, &working_dir);
}
+ // --tree: show dependency tree (uses lock file)
+ if args.tree {
+ return show_tree(args, &working_dir);
+ }
+
+ // --available: show available versions for installed packages
+ if args.available {
+ return show_available(args, &working_dir);
+ }
+
// --locked: show from lock file
if args.locked {
return execute_locked(args, &working_dir);
@@ -257,6 +254,9 @@ fn show_installed_package_list(
args: &ShowArgs,
_vendor_dir: &Path,
) -> anyhow::Result<()> {
+ // --latest / --outdated: fetch latest versions from Packagist
+ let show_latest = args.latest || args.outdated;
+
if args.name_only {
for pkg in packages {
println!("{}", pkg.name);
@@ -268,29 +268,246 @@ fn show_installed_package_list(
return Ok(());
}
- // Calculate column widths for alignment
- let name_width = packages.iter().map(|p| p.name.len()).max().unwrap_or(0);
- let version_width = packages
+ // Build ignore set
+ let ignore_set: HashSet<String> = args.ignore.iter().map(|n| n.to_lowercase()).collect();
+
+ // Gather entries (fetch latest if needed, apply outdated filter)
+ let mut entries: Vec<InstalledListEntry> = Vec::new();
+ for pkg in packages {
+ if ignore_set.contains(&pkg.name.to_lowercase()) {
+ continue;
+ }
+
+ let version_normalized = pkg
+ .version_normalized
+ .clone()
+ .unwrap_or_else(|| normalize_version_simple(&pkg.version));
+ let description = get_installed_description(pkg);
+
+ let latest_info = if show_latest {
+ fetch_latest_for_package(&pkg.name).ok()
+ } else {
+ None
+ };
+
+ // --outdated: skip packages that are up-to-date
+ if args.outdated {
+ if let Some(ref li) = latest_info {
+ use crate::version::compare_normalized_versions;
+ use std::cmp::Ordering;
+ if compare_normalized_versions(&li.version_normalized, &version_normalized)
+ != Ordering::Greater
+ {
+ continue;
+ }
+ } else {
+ // Cannot determine latest: skip
+ continue;
+ }
+ }
+
+ entries.push(InstalledListEntry {
+ name: pkg.name.clone(),
+ version: pkg.version.clone(),
+ version_normalized,
+ description,
+ latest_info,
+ });
+ }
+
+ // --strict: exit 1 if any outdated
+ let has_outdated = entries.iter().any(|e| e.latest_info.is_some());
+
+ // JSON output
+ let format = args.format.as_deref().unwrap_or("text");
+ if format == "json" {
+ render_installed_json(&entries)?;
+ if args.strict && has_outdated {
+ std::process::exit(1);
+ }
+ return Ok(());
+ }
+
+ // Text output
+ let name_width = entries.iter().map(|e| e.name.len()).max().unwrap_or(0);
+ let version_width = entries
.iter()
- .map(|p| format_version(&p.version).len())
+ .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 pkg in packages {
- let version = format_version(&pkg.version);
- let description = get_installed_description(pkg);
+ 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));
- println!(
- "{} {} {}",
- crate::console::info(&format!("{:<width$}", pkg.name, width = name_width)),
- crate::console::comment(&format!("{:<width$}", version, width = version_width)),
- description
- );
+ let name_str = match category {
+ Some(ListUpdateKind::Compatible) => {
+ crate::console::highlight(&format!("{:<width$}", entry.name, width = name_width))
+ .to_string()
+ }
+ Some(ListUpdateKind::Incompatible) => {
+ crate::console::comment(&format!("{:<width$}", entry.name, width = name_width))
+ .to_string()
+ }
+ _ => crate::console::info(&format!("{:<width$}", entry.name, width = name_width))
+ .to_string(),
+ };
+
+ let version_str =
+ crate::console::comment(&format!("{:<width$}", version, width = version_width))
+ .to_string();
+
+ 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) => crate::console::highlight(&format!(
+ "{:<width$}",
+ lv,
+ width = latest_width
+ ))
+ .to_string(),
+ Some(ListUpdateKind::Incompatible) => crate::console::comment(&format!(
+ "{:<width$}",
+ lv,
+ width = latest_width
+ ))
+ .to_string(),
+ _ => crate::console::info(&format!("{:<width$}", lv, width = latest_width))
+ .to_string(),
+ }
+ }
+ None => format!("{:<width$}", "", width = latest_width),
+ };
+ println!(
+ "{} {} {} {}",
+ name_str, version_str, latest_str, entry.description
+ );
+ } else {
+ println!("{} {} {}", name_str, version_str, entry.description);
+ }
+ }
+
+ if args.strict && has_outdated {
+ std::process::exit(1);
}
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 crate::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
+ } else {
+ ListUpdateKind::Incompatible
+ }
+}
+
+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)
+}
+
+fn fetch_latest_for_package(name: &str) -> anyhow::Result<LatestInfo> {
+ use crate::package::Stability;
+ use crate::version::find_best_candidate;
+
+ let versions = crate::packagist::fetch_package_versions(name)?;
+ 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]) -> 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();
+
+ let output = serde_json::json!({ "installed": json_entries });
+ println!("{}", serde_json::to_string_pretty(&output)?);
+ Ok(())
+}
+
fn show_installed_package_detail(
installed: &crate::installed::InstalledPackages,
package_name: &str,
@@ -473,6 +690,8 @@ fn show_locked_package_list(
packages: &[&crate::lockfile::LockedPackage],
args: &ShowArgs,
) -> anyhow::Result<()> {
+ let show_latest = args.latest || args.outdated;
+
if args.name_only {
for pkg in packages {
println!("{}", pkg.name);
@@ -484,25 +703,184 @@ fn show_locked_package_list(
return Ok(());
}
- let name_width = packages.iter().map(|p| p.name.len()).max().unwrap_or(0);
- let version_width = packages
+ // Build ignore set
+ let ignore_set: HashSet<String> = args.ignore.iter().map(|n| n.to_lowercase()).collect();
+
+ // Gather entries
+ let mut entries: Vec<LockedListEntry> = Vec::new();
+ for pkg in packages {
+ if ignore_set.contains(&pkg.name.to_lowercase()) {
+ continue;
+ }
+
+ 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).ok()
+ } else {
+ None
+ };
+
+ // --outdated: skip packages that are up-to-date
+ if args.outdated {
+ if let Some(ref li) = latest_info {
+ use crate::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)?;
+ if args.strict && has_outdated {
+ std::process::exit(1);
+ }
+ return Ok(());
+ }
+
+ // Text format
+ let name_width = entries.iter().map(|e| e.name.len()).max().unwrap_or(0);
+ let version_width = entries
.iter()
- .map(|p| format_version(&p.version).len())
+ .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 pkg in packages {
- let version = format_version(&pkg.version);
- let description = pkg.description.as_deref().unwrap_or("");
+ 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));
- println!(
- "{} {} {}",
- crate::console::info(&format!("{:<width$}", pkg.name, width = name_width)),
- crate::console::comment(&format!("{:<width$}", version, width = version_width)),
- description
- );
+ let name_str = match category {
+ Some(ListUpdateKind::Compatible) => {
+ crate::console::highlight(&format!("{:<width$}", entry.name, width = name_width))
+ .to_string()
+ }
+ Some(ListUpdateKind::Incompatible) => {
+ crate::console::comment(&format!("{:<width$}", entry.name, width = name_width))
+ .to_string()
+ }
+ _ => crate::console::info(&format!("{:<width$}", entry.name, width = name_width))
+ .to_string(),
+ };
+
+ let version_str =
+ crate::console::comment(&format!("{:<width$}", version, width = version_width))
+ .to_string();
+
+ 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) => crate::console::highlight(&format!(
+ "{:<width$}",
+ lv,
+ width = latest_width
+ ))
+ .to_string(),
+ Some(ListUpdateKind::Incompatible) => crate::console::comment(&format!(
+ "{:<width$}",
+ lv,
+ width = latest_width
+ ))
+ .to_string(),
+ _ => crate::console::info(&format!("{:<width$}", lv, width = latest_width))
+ .to_string(),
+ }
+ }
+ None => format!("{:<width$}", "", width = latest_width),
+ };
+ println!(
+ "{} {} {} {}",
+ name_str, version_str, latest_str, entry.description
+ );
+ } else {
+ println!("{} {} {}", name_str, version_str, entry.description);
+ }
}
+ if args.strict && has_outdated {
+ std::process::exit(1);
+ }
+
+ Ok(())
+}
+
+struct LockedListEntry {
+ name: String,
+ version: String,
+ version_normalized: String,
+ description: String,
+ latest_info: Option<LatestInfo>,
+}
+
+fn render_locked_json(entries: &[LockedListEntry]) -> 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();
+
+ let output = serde_json::json!({ "installed": json_entries });
+ println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
@@ -671,6 +1049,500 @@ fn show_self(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> {
Ok(())
}
+// ─── Tree mode ─────────────────────────────────────────────────────────────
+
+fn show_tree(args: &ShowArgs, working_dir: &Path) -> anyhow::Result<()> {
+ let lock_path = working_dir.join("composer.lock");
+ let composer_json_path = working_dir.join("composer.json");
+
+ if !composer_json_path.exists() {
+ anyhow::bail!("No composer.json found in {}", working_dir.display());
+ }
+
+ let root = crate::package::read_from_file(&composer_json_path)?;
+
+ // Load all locked packages into a map for quick lookup
+ let pkg_map: HashMap<String, &crate::lockfile::LockedPackage>;
+ let lock_storage;
+ if lock_path.exists() {
+ lock_storage = crate::lockfile::LockFile::read_from_file(&lock_path)?;
+ pkg_map = lock_storage
+ .packages
+ .iter()
+ .chain(lock_storage.packages_dev.iter().flatten())
+ .map(|p| (p.name.to_lowercase(), p))
+ .collect();
+ } else {
+ pkg_map = HashMap::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()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+ if !args.no_dev {
+ reqs.extend(root.require_dev.iter().map(|(k, v)| (k.clone(), v.clone())));
+ }
+ reqs.sort_by(|a, b| a.0.cmp(&b.0));
+ reqs
+ };
+
+ // Print root
+ println!(
+ "{} {}",
+ crate::console::info(&root.name),
+ crate::console::comment(root.description.as_deref().unwrap_or(""))
+ );
+
+ // Render each root dependency as a tree
+ let mut visited_global: HashSet<String> = HashSet::new();
+ let count = root_reqs.len();
+ for (i, (dep_name, dep_constraint)) in root_reqs.iter().enumerate() {
+ let is_last = i == count - 1;
+ let prefix = if is_last { "└──" } else { "├──" };
+ let child_prefix = if is_last { " " } else { "│ " };
+
+ print_tree_node(
+ dep_name,
+ dep_constraint,
+ &pkg_map,
+ prefix,
+ child_prefix,
+ &mut visited_global,
+ 0,
+ );
+ }
+
+ Ok(())
+}
+
+fn print_tree_node(
+ pkg_name: &str,
+ constraint: &str,
+ pkg_map: &HashMap<String, &crate::lockfile::LockedPackage>,
+ prefix: &str,
+ child_prefix: &str,
+ visited: &mut HashSet<String>,
+ depth: usize,
+) {
+ const MAX_DEPTH: usize = 10;
+
+ 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);
+
+ println!(
+ "{} {} {} {}",
+ prefix,
+ crate::console::info(pkg_name),
+ crate::console::comment(&version),
+ description
+ );
+
+ // Detect circular dependency or depth limit
+ if visited.contains(&key) || depth >= MAX_DEPTH {
+ if visited.contains(&key) {
+ println!("{} {} (circular dependency)", child_prefix, pkg_name);
+ }
+ return;
+ }
+
+ 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;
+ }
+ let is_last_child = ci == child_count - 1;
+ let child_node_prefix = format!(
+ "{}{}",
+ child_prefix,
+ if is_last_child {
+ "└──"
+ } else {
+ "├──"
+ }
+ );
+ let grandchild_prefix = format!(
+ "{}{}",
+ child_prefix,
+ if is_last_child { " " } else { "│ " }
+ );
+
+ print_tree_node(
+ child_name,
+ child_constraint,
+ pkg_map,
+ &child_node_prefix,
+ &grandchild_prefix,
+ visited,
+ depth + 1,
+ );
+ }
+
+ visited.remove(&key);
+ } else {
+ // Package not found in lock file (platform package or not installed)
+ if !is_platform_package(&key) {
+ println!(
+ "{} {} {} (not installed)",
+ prefix,
+ crate::console::comment(pkg_name),
+ constraint
+ );
+ }
+ }
+}
+
+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) -> anyhow::Result<()> {
+ // Collect platform info from lock file and system detection
+ let mut platform_packages: Vec<(String, String, String)> = Vec::new(); // (name, version, source)
+
+ // Try to detect PHP from the system
+ let php_version = 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 = crate::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();
+ platform_packages.push((name.clone(), version_str, "lock".to_string()));
+ }
+ }
+ if let Some(obj) = lock.platform_dev.as_object()
+ && !args.no_dev
+ {
+ 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()));
+ }
+ }
+ }
+ }
+
+ // 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 = detect_php_extensions();
+ for ext in &extensions {
+ let ext_name = format!("ext-{ext}");
+ if !platform_packages.iter().any(|(n, _, _)| *n == ext_name) {
+ platform_packages.push((ext_name, "*".to_string(), "detected".to_string()));
+ }
+ }
+
+ // 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
+ .iter()
+ .map(|(name, version, source)| {
+ serde_json::json!({
+ "name": name,
+ "version": version,
+ "source": source,
+ })
+ })
+ .collect();
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&serde_json::json!({ "platform": json_entries }))?
+ );
+ return Ok(());
+ }
+
+ if platform_packages.is_empty() {
+ eprintln!(
+ "No platform packages detected. Install PHP or add platform requirements to composer.json."
+ );
+ return Ok(());
+ }
+
+ if args.name_only {
+ for (name, _, _) in &platform_packages {
+ println!("{name}");
+ }
+ return Ok(());
+ }
+
+ let name_width = platform_packages
+ .iter()
+ .map(|(n, _, _)| n.len())
+ .max()
+ .unwrap_or(0);
+ let version_width = platform_packages
+ .iter()
+ .map(|(_, v, _)| v.len())
+ .max()
+ .unwrap_or(0);
+
+ for (name, version, _source) in &platform_packages {
+ println!(
+ "{} {}",
+ crate::console::info(&format!("{:<width$}", name, width = name_width)),
+ crate::console::comment(&format!("{:<width$}", version, width = version_width)),
+ );
+ }
+
+ Ok(())
+}
+
+/// Try to detect the installed PHP version by running `php --version`.
+fn detect_php_version() -> Option<String> {
+ let output = std::process::Command::new("php")
+ .arg("--version")
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ // Parse "PHP 8.2.1 (cli) ..." → "8.2.1"
+ let first_line = stdout.lines().next()?;
+ let parts: Vec<&str> = first_line.split_whitespace().collect();
+ if parts.len() >= 2 && parts[0] == "PHP" {
+ Some(parts[1].to_string())
+ } else {
+ None
+ }
+}
+
+/// Try to detect PHP extensions by running `php -m`.
+fn detect_php_extensions() -> Vec<String> {
+ let output = match std::process::Command::new("php").arg("-m").output() {
+ Ok(o) => o,
+ Err(_) => return vec![],
+ };
+
+ if !output.status.success() {
+ return vec![];
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ stdout
+ .lines()
+ .filter(|line| {
+ let l = line.trim();
+ !l.is_empty()
+ && !l.starts_with('[')
+ && l.chars()
+ .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
+ })
+ .map(|l| l.trim().to_lowercase())
+ .collect()
+}
+
+// ─── Available mode ─────────────────────────────────────────────────────────
+
+fn show_available(args: &ShowArgs, working_dir: &Path) -> 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, args);
+ }
+
+ // 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 = crate::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 = crate::lockfile::LockFile::read_from_file(&lock_path)?;
+ println!(
+ "{}",
+ crate::console::info(
+ "Available versions for locked packages (from Packagist):"
+ )
+ );
+ println!();
+
+ let mut all_packages: Vec<&crate::lockfile::LockedPackage> =
+ lock.packages.iter().collect();
+ if !args.no_dev
+ && let Some(ref dev_pkgs) = lock.packages_dev
+ {
+ all_packages.extend(dev_pkgs.iter());
+ }
+
+ for pkg in &all_packages {
+ if is_platform_package(&pkg.name) {
+ continue;
+ }
+ show_available_versions_inline(&pkg.name);
+ }
+ return Ok(());
+ }
+
+ eprintln!(
+ "{}",
+ crate::console::warning(
+ "No dependencies installed. Try running mozart install or update."
+ )
+ );
+ return Ok(());
+ }
+ };
+
+ println!(
+ "{}",
+ crate::console::info("Available versions for installed packages (from Packagist):")
+ );
+ println!();
+
+ let format = args.format.as_deref().unwrap_or("text");
+
+ if format == "json" {
+ let mut json_entries: Vec<serde_json::Value> = Vec::new();
+ for pkg in &installed.packages {
+ if is_platform_package(&pkg.name) {
+ continue;
+ }
+ match crate::packagist::fetch_package_versions(&pkg.name) {
+ Ok(versions) => {
+ let version_strings: Vec<String> =
+ versions.iter().map(|v| v.version.clone()).collect();
+ json_entries.push(serde_json::json!({
+ "name": pkg.name,
+ "installed": pkg.version,
+ "available": version_strings,
+ }));
+ }
+ Err(_) => {
+ json_entries.push(serde_json::json!({
+ "name": pkg.name,
+ "installed": pkg.version,
+ "available": [],
+ }));
+ }
+ }
+ }
+ let output = serde_json::json!({ "packages": json_entries });
+ println!("{}", serde_json::to_string_pretty(&output)?);
+ return Ok(());
+ }
+
+ for pkg in &installed.packages {
+ if is_platform_package(&pkg.name) {
+ continue;
+ }
+ show_available_versions_inline(&pkg.name);
+ }
+
+ Ok(())
+}
+
+fn show_available_versions(pkg_name: &str, args: &ShowArgs) -> anyhow::Result<()> {
+ let versions = crate::packagist::fetch_package_versions(pkg_name)?;
+ if versions.is_empty() {
+ println!("No versions found for {pkg_name}");
+ return Ok(());
+ }
+
+ let format = args.format.as_deref().unwrap_or("text");
+ if format == "json" {
+ let version_strings: Vec<String> = versions.iter().map(|v| v.version.clone()).collect();
+ let output = serde_json::json!({
+ "name": pkg_name,
+ "versions": version_strings,
+ });
+ println!("{}", serde_json::to_string_pretty(&output)?);
+ return Ok(());
+ }
+
+ println!(
+ "{}",
+ crate::console::info(&format!("Available versions for {pkg_name}:"))
+ );
+ for v in &versions {
+ println!(" {}", crate::console::comment(&v.version));
+ }
+ Ok(())
+}
+
+fn show_available_versions_inline(pkg_name: &str) {
+ match crate::packagist::fetch_package_versions(pkg_name) {
+ Ok(versions) => {
+ if versions.is_empty() {
+ println!("{}: no versions found", crate::console::info(pkg_name));
+ return;
+ }
+ // Show up to 5 most recent versions
+ let shown: Vec<&str> = versions
+ .iter()
+ .take(5)
+ .map(|v| v.version.as_str())
+ .collect();
+ let rest = if versions.len() > 5 {
+ format!(" (+{} more)", versions.len() - 5)
+ } else {
+ String::new()
+ };
+ println!(
+ "{}: {}{}",
+ crate::console::info(pkg_name),
+ crate::console::comment(&shown.join(", ")),
+ rest
+ );
+ }
+ Err(_) => {
+ println!(
+ "{}: (could not fetch from Packagist)",
+ crate::console::comment(pkg_name)
+ );
+ }
+ }
+}
+
// ─── Helper functions ──────────────────────────────────────────────────────
/// Format version string for display: strip leading 'v' for text output.
@@ -773,6 +1645,26 @@ fn matches_wildcard(name: &str, pattern: &str) -> bool {
true
}
+/// 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('-') {
+ (&v[..pos], Some(&v[pos..]))
+ } else {
+ (v, None)
+ };
+ let parts: Vec<&str> = base.split('.').collect();
+ let mut segments: Vec<String> = parts.iter().take(4).map(|p| p.to_string()).collect();
+ while segments.len() < 4 {
+ segments.push("0".to_string());
+ }
+ let mut result = segments.join(".");
+ if let Some(suf) = suffix {
+ result.push_str(suf);
+ }
+ result
+}
+
// ─── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
@@ -919,4 +1811,84 @@ mod tests {
};
assert_eq!(get_installed_keywords(&pkg), "log, psr3, logging");
}
+
+ // ── is_platform_package ───────────────────────────────────────────────────
+
+ #[test]
+ fn test_is_platform_package_php() {
+ assert!(is_platform_package("php"));
+ }
+
+ #[test]
+ fn test_is_platform_package_ext() {
+ assert!(is_platform_package("ext-json"));
+ assert!(is_platform_package("ext-mbstring"));
+ }
+
+ #[test]
+ fn test_is_platform_package_lib() {
+ assert!(is_platform_package("lib-pcre"));
+ }
+
+ #[test]
+ fn test_is_platform_package_not_platform() {
+ assert!(!is_platform_package("monolog/monolog"));
+ assert!(!is_platform_package("psr/log"));
+ }
+
+ // ── classify_update_category ─────────────────────────────────────────────
+
+ #[test]
+ fn test_classify_up_to_date() {
+ assert_eq!(
+ classify_update_category("1.2.3.0", "1.2.3.0"),
+ ListUpdateKind::UpToDate
+ );
+ }
+
+ #[test]
+ fn test_classify_compatible_same_major() {
+ assert_eq!(
+ classify_update_category("1.2.0.0", "1.3.0.0"),
+ ListUpdateKind::Compatible
+ );
+ }
+
+ #[test]
+ fn test_classify_incompatible_different_major() {
+ assert_eq!(
+ classify_update_category("1.9.0.0", "2.0.0.0"),
+ ListUpdateKind::Incompatible
+ );
+ }
+
+ // ── normalize_version_simple ──────────────────────────────────────────────
+
+ #[test]
+ fn test_normalize_version_simple_short() {
+ assert_eq!(normalize_version_simple("1.2"), "1.2.0.0");
+ }
+
+ #[test]
+ fn test_normalize_version_simple_three_parts() {
+ assert_eq!(normalize_version_simple("1.2.3"), "1.2.3.0");
+ }
+
+ #[test]
+ fn test_normalize_version_simple_v_prefix() {
+ assert_eq!(normalize_version_simple("v1.2.3"), "1.2.3.0");
+ }
+
+ // ── extract_major ─────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_extract_major_basic() {
+ assert_eq!(extract_major("2.3.4.0"), 2);
+ assert_eq!(extract_major("0.1.2.0"), 0);
+ }
+
+ #[test]
+ fn test_extract_major_with_prerelease() {
+ assert_eq!(extract_major("2.3.4.0-beta1"), 2);
+ }
}