From b419325ed772bb62b344ac7c23dd8890e01a17af Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 21 Feb 2026 14:36:32 +0900 Subject: feat(show): implement --tree, --latest, --outdated, --available, --platform, and --format=json Replace stub messages with full implementations for all show command extensions: dependency tree rendering from lock file, latest version fetching from Packagist, outdated filtering with update classification, available versions listing, platform package detection via PHP CLI, and JSON output format across all modes. Co-Authored-By: Claude Opus 4.6 --- crates/mozart/src/commands/show.rs | 1060 ++++++++++++++++++++++++++++++++++-- 1 file changed, 1016 insertions(+), 44 deletions(-) (limited to 'crates/mozart/src/commands/show.rs') 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,26 +268,243 @@ 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 = args.ignore.iter().map(|n| n.to_lowercase()).collect(); + + // Gather entries (fetch latest if needed, apply outdated filter) + let mut entries: Vec = 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)); + + let name_str = match category { + Some(ListUpdateKind::Compatible) => { + crate::console::highlight(&format!("{: { + crate::console::comment(&format!("{: crate::console::info(&format!("{: { + let lv = format_version(&li.version); + match category { + Some(ListUpdateKind::Compatible) => crate::console::highlight(&format!( + "{: crate::console::comment(&format!( + "{: crate::console::info(&format!("{: format!("{:, +} + +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 { + 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 = 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(()) } @@ -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,28 +703,187 @@ 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 = args.ignore.iter().map(|n| n.to_lowercase()).collect(); + + // Gather entries + let mut entries: Vec = 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)); + + let name_str = match category { + Some(ListUpdateKind::Compatible) => { + crate::console::highlight(&format!("{: { + crate::console::comment(&format!("{: crate::console::info(&format!("{: { + let lv = format_version(&li.version); + match category { + Some(ListUpdateKind::Compatible) => crate::console::highlight(&format!( + "{: crate::console::comment(&format!( + "{: crate::console::info(&format!("{: format!("{:, +} + +fn render_locked_json(entries: &[LockedListEntry]) -> anyhow::Result<()> { + let json_entries: Vec = entries + .iter() + .map(|entry| { + let mut obj = serde_json::json!({ + "name": entry.name, + "version": entry.version, + "description": entry.description, + }); + if let Some(ref li) = entry.latest_info { + obj["latest"] = serde_json::Value::String(li.version.clone()); + let status = + if classify_update_category(&entry.version_normalized, &li.version_normalized) + == ListUpdateKind::UpToDate + { + "up-to-date" + } else { + "outdated" + }; + obj["latest-status"] = serde_json::Value::String(status.to_string()); + } + obj + }) + .collect(); + + let output = serde_json::json!({ "installed": json_entries }); + println!("{}", serde_json::to_string_pretty(&output)?); + Ok(()) +} + fn show_locked_package_detail( lock: &crate::lockfile::LockFile, package_name: &str, @@ -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; + 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 = 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, + prefix: &str, + child_prefix: &str, + visited: &mut HashSet, + 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 = 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!("{: Option { + 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 { + 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 = 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 = + 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 = 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 = 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); + } } -- cgit v1.3.1