use clap::Args; use indexmap::IndexSet; use mozart_core::console::Console; use mozart_core::console_format; use mozart_core::console_writeln; use serde::Serialize; use std::path::Path; #[derive(Args)] pub struct LicensesArgs { /// Output format (text, json, summary) #[arg(short, long)] pub format: Option, /// Disables listing of require-dev packages #[arg(long)] pub no_dev: bool, /// List packages from the lock file #[arg(long)] pub locked: bool, } struct LicenseEntry { name: String, version: String, licenses: Vec, } pub async fn execute( args: &LicensesArgs, cli: &super::Cli, console: &mozart_core::console::Console, ) -> anyhow::Result<()> { let working_dir = cli.working_dir()?; // Validate format let format = args.format.as_deref().unwrap_or("text"); if format != "text" && format != "json" && format != "summary" { anyhow::bail!( "Unsupported format \"{}\". See help for supported formats.", format ); } // Load root package 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 = mozart_core::package::read_from_file(&composer_json_path)?; let root_name = root.name.clone(); let root_version = root .extra_fields .get("version") .and_then(|v| v.as_str()) .unwrap_or("No version set") .to_string(); // Parse root license as Vec: composer.json allows either a string or an array. let root_licenses: Vec = { // Read the raw JSON value so we can handle both string and array forms. let raw_json = std::fs::read_to_string(&composer_json_path)?; let raw_value: serde_json::Value = serde_json::from_str(&raw_json)?; match raw_value.get("license") { Some(serde_json::Value::String(s)) => vec![s.clone()], Some(serde_json::Value::Array(arr)) => arr .iter() .filter_map(|v| v.as_str()) .map(|s| s.to_string()) .collect(), _ => vec![], } }; // Load dependency entries let entries = if args.locked { load_locked_licenses(&working_dir, args.no_dev)? } else { load_installed_licenses(&working_dir, args.no_dev)? }; // Render output match format { "json" => render_json(&root_name, &root_version, &root_licenses, &entries, console)?, "summary" => render_summary(&entries, console), _ => render_text(&root_name, &root_version, &root_licenses, &entries, console), } Ok(()) } fn load_installed_licenses(working_dir: &Path, no_dev: bool) -> anyhow::Result> { let vendor_dir = working_dir.join("vendor"); let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; let dev_names: IndexSet = installed .dev_package_names .iter() .map(|n| n.to_lowercase()) .collect(); let mut entries: Vec = installed .packages .iter() .filter(|p| { if no_dev && dev_names.contains(&p.name.to_lowercase()) { return false; } true }) .map(|p| LicenseEntry { name: p.name.clone(), version: p.version.clone(), licenses: extract_installed_licenses(p), }) .collect(); entries.sort_by_key(|a| a.name.to_lowercase()); Ok(entries) } fn load_locked_licenses(working_dir: &Path, no_dev: bool) -> anyhow::Result> { let lock_path = working_dir.join("composer.lock"); if !lock_path.exists() { anyhow::bail!( "Valid composer.json and composer.lock files are required to run this command with --locked" ); } let lock = mozart_registry::lockfile::LockFile::read_from_file(&lock_path)?; let mut all_packages: Vec<&mozart_registry::lockfile::LockedPackage> = lock.packages.iter().collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { all_packages.extend(pkgs_dev.iter()); } let mut entries: Vec = all_packages .iter() .map(|p| LicenseEntry { name: p.name.clone(), version: p.version.clone(), licenses: p.license.clone().unwrap_or_default(), }) .collect(); entries.sort_by_key(|a| a.name.to_lowercase()); Ok(entries) } fn extract_installed_licenses( pkg: &mozart_registry::installed::InstalledPackageEntry, ) -> Vec { pkg.extra_fields .get("license") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str()) .map(|s| s.to_string()) .collect() }) .unwrap_or_default() } fn count_licenses(entries: &[LicenseEntry]) -> Vec<(String, usize)> { let mut counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); for entry in entries { if entry.licenses.is_empty() { *counts.entry("none".to_string()).or_insert(0) += 1; } else { for lic in &entry.licenses { *counts.entry(lic.clone()).or_insert(0) += 1; } } } let mut result: Vec<(String, usize)> = counts.into_iter().collect(); // Sort by count descending, then by name ascending for stability result.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); result } fn render_text( root_name: &str, root_version: &str, root_licenses: &[String], entries: &[LicenseEntry], console: &Console, ) { let license_display = if root_licenses.is_empty() { "none".to_string() } else { root_licenses.join(", ") }; console_writeln!( console, &console_format!("Name: {root_name}"), ); console_writeln!( console, &console_format!("Version: {root_version}"), ); console_writeln!( console, &console_format!("Licenses: {license_display}"), ); console_writeln!(console, "Dependencies:"); console_writeln!(console, ""); if entries.is_empty() { return; } let name_width = entries .iter() .map(|e| e.name.len()) .max() .unwrap_or(0) .max("Name".len()); let version_width = entries .iter() .map(|e| e.version.len()) .max() .unwrap_or(0) .max("Version".len()); console_writeln!( console, &format!( "{: anyhow::Result<()> { let root_license_arr: Vec = root_licenses .iter() .map(|s| serde_json::Value::String(s.clone())) .collect(); let mut dependencies: serde_json::Map = serde_json::Map::new(); for entry in entries { let license_arr: Vec = entry .licenses .iter() .map(|l| serde_json::Value::String(l.clone())) .collect(); dependencies.insert( entry.name.clone(), serde_json::json!({ "version": entry.version, "license": license_arr, }), ); } let output = serde_json::json!({ "name": root_name, "version": root_version, "license": root_license_arr, "dependencies": dependencies, }); let buf = Vec::new(); let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); let mut ser = serde_json::Serializer::with_formatter(buf, formatter); output.serialize(&mut ser)?; console_writeln!(console, &String::from_utf8(ser.into_inner())?,); Ok(()) } fn render_summary(entries: &[LicenseEntry], console: &Console) { let counts = count_licenses(entries); if counts.is_empty() { console_writeln!(console, "No dependencies found."); return; } const COL2_HEADER: &str = "Number of dependencies"; let license_width = counts .iter() .map(|(l, _)| l.len()) .max() .unwrap_or(0) .max("License".len()); let count_width = counts .iter() .map(|(_, c)| c.to_string().len()) .max() .unwrap_or(0) .max(COL2_HEADER.len()); let border_col1 = "-".repeat(license_width + 2); let border_col2 = "-".repeat(count_width + 2); console_writeln!(console, &format!(" {} {}", border_col1, border_col2),); console_writeln!( console, &format!( " {:, ) -> mozart_registry::installed::InstalledPackageEntry { mozart_registry::installed::InstalledPackageEntry { name: name.to_string(), version: version.to_string(), version_normalized: None, source: None, dist: None, package_type: None, install_path: None, autoload: None, aliases: vec![], extra_fields: extra, } } #[test] fn test_extract_installed_licenses_present() { let mut extra = BTreeMap::new(); extra.insert("license".to_string(), serde_json::json!(["MIT"])); let pkg = make_installed_pkg("vendor/pkg", "1.0.0", extra); assert_eq!(extract_installed_licenses(&pkg), vec!["MIT"]); } #[test] fn test_extract_installed_licenses_multiple() { let mut extra = BTreeMap::new(); extra.insert( "license".to_string(), serde_json::json!(["MIT", "Apache-2.0"]), ); let pkg = make_installed_pkg("vendor/pkg", "1.0.0", extra); let result = extract_installed_licenses(&pkg); assert_eq!(result, vec!["MIT", "Apache-2.0"]); } #[test] fn test_extract_installed_licenses_absent() { let pkg = make_installed_pkg("vendor/pkg", "1.0.0", BTreeMap::new()); assert!(extract_installed_licenses(&pkg).is_empty()); } #[test] fn test_extract_installed_licenses_none_value() { let mut extra = BTreeMap::new(); extra.insert("license".to_string(), serde_json::Value::Null); let pkg = make_installed_pkg("vendor/pkg", "1.0.0", extra); assert!(extract_installed_licenses(&pkg).is_empty()); } #[test] fn test_count_licenses() { let entries = vec![ LicenseEntry { name: "a/a".to_string(), version: "1.0.0".to_string(), licenses: vec!["MIT".to_string()], }, LicenseEntry { name: "b/b".to_string(), version: "1.0.0".to_string(), licenses: vec!["MIT".to_string()], }, LicenseEntry { name: "c/c".to_string(), version: "1.0.0".to_string(), licenses: vec!["Apache-2.0".to_string()], }, ]; let counts = count_licenses(&entries); assert_eq!(counts.len(), 2); // MIT should come first (count=2) assert_eq!(counts[0], ("MIT".to_string(), 2)); assert_eq!(counts[1], ("Apache-2.0".to_string(), 1)); } #[test] fn test_count_licenses_empty() { let entries: Vec = vec![]; let counts = count_licenses(&entries); assert!(counts.is_empty()); } #[test] fn test_count_licenses_no_license() { let entries = vec![LicenseEntry { name: "a/a".to_string(), version: "1.0.0".to_string(), licenses: vec![], }]; let counts = count_licenses(&entries); assert_eq!(counts.len(), 1); assert_eq!(counts[0], ("none".to_string(), 1)); } #[test] fn test_load_installed_licenses_basic() { use tempfile::tempdir; let dir = tempdir().unwrap(); let working_dir = dir.path(); let vendor_dir = working_dir.join("vendor"); // Write composer.json (required by execute, but not needed for load_installed_licenses) std::fs::write( working_dir.join("composer.json"), r#"{"name": "test/project"}"#, ) .unwrap(); // Build installed packages let mut installed = mozart_registry::installed::InstalledPackages::new(); let mut extra = BTreeMap::new(); extra.insert("license".to_string(), serde_json::json!(["MIT"])); installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, source: None, dist: None, package_type: None, install_path: None, autoload: None, aliases: vec![], extra_fields: extra, }); installed.write(&vendor_dir).unwrap(); let entries = load_installed_licenses(working_dir, false).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "monolog/monolog"); assert_eq!(entries[0].version, "3.0.0"); assert_eq!(entries[0].licenses, vec!["MIT"]); } #[test] fn test_load_installed_licenses_no_dev() { use tempfile::tempdir; let dir = tempdir().unwrap(); let working_dir = dir.path(); let vendor_dir = working_dir.join("vendor"); std::fs::write( working_dir.join("composer.json"), r#"{"name": "test/project"}"#, ) .unwrap(); let mut installed = mozart_registry::installed::InstalledPackages::new(); // Production package let mut extra_prod = BTreeMap::new(); extra_prod.insert("license".to_string(), serde_json::json!(["MIT"])); installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, source: None, dist: None, package_type: None, install_path: None, autoload: None, aliases: vec![], extra_fields: extra_prod, }); // Dev package let mut extra_dev = BTreeMap::new(); extra_dev.insert("license".to_string(), serde_json::json!(["BSD-3-Clause"])); installed.upsert(mozart_registry::installed::InstalledPackageEntry { name: "phpunit/phpunit".to_string(), version: "10.0.0".to_string(), version_normalized: None, source: None, dist: None, package_type: None, install_path: None, autoload: None, aliases: vec![], extra_fields: extra_dev, }); installed .dev_package_names .push("phpunit/phpunit".to_string()); installed.write(&vendor_dir).unwrap(); // With --no-dev: only production package let entries = load_installed_licenses(working_dir, true).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "monolog/monolog"); // Without --no-dev: both packages let entries_all = load_installed_licenses(working_dir, false).unwrap(); assert_eq!(entries_all.len(), 2); } #[test] fn test_load_locked_licenses_basic() { use mozart_registry::lockfile::{LockFile, LockedPackage}; use tempfile::tempdir; let dir = tempdir().unwrap(); let working_dir = dir.path(); std::fs::write( working_dir.join("composer.json"), r#"{"name": "test/project"}"#, ) .unwrap(); let lock = LockFile { readme: LockFile::default_readme(), content_hash: "abc123".to_string(), packages: vec![LockedPackage { name: "psr/log".to_string(), version: "3.0.0".to_string(), version_normalized: None, source: None, dist: None, require: BTreeMap::new(), require_dev: BTreeMap::new(), conflict: BTreeMap::new(), provide: BTreeMap::new(), replace: BTreeMap::new(), suggest: None, package_type: None, autoload: None, autoload_dev: None, license: Some(vec!["MIT".to_string()]), description: None, homepage: None, keywords: None, authors: None, support: None, funding: None, time: None, extra_fields: BTreeMap::new(), }], packages_dev: Some(vec![LockedPackage { name: "phpunit/phpunit".to_string(), version: "10.0.0".to_string(), version_normalized: None, source: None, dist: None, require: BTreeMap::new(), require_dev: BTreeMap::new(), conflict: BTreeMap::new(), provide: BTreeMap::new(), replace: BTreeMap::new(), suggest: None, package_type: None, autoload: None, autoload_dev: None, license: Some(vec!["BSD-3-Clause".to_string()]), description: None, homepage: None, keywords: None, authors: None, support: None, funding: None, time: None, extra_fields: BTreeMap::new(), }]), aliases: vec![], minimum_stability: "stable".to_string(), stability_flags: serde_json::json!({}), prefer_stable: false, prefer_lowest: false, platform: serde_json::json!({}), platform_dev: serde_json::json!({}), plugin_api_version: Some("2.6.0".to_string()), }; lock.write_to_file(&working_dir.join("composer.lock")) .unwrap(); // With --no-dev: only production packages let entries = load_locked_licenses(working_dir, true).unwrap(); assert_eq!(entries.len(), 1); assert_eq!(entries[0].name, "psr/log"); assert_eq!(entries[0].licenses, vec!["MIT"]); // Without --no-dev: both packages let entries_all = load_locked_licenses(working_dir, false).unwrap(); assert_eq!(entries_all.len(), 2); } #[test] fn test_root_license_array_in_json() { use tempfile::tempdir; let dir = tempdir().unwrap(); let working_dir = dir.path(); let composer_json_path = working_dir.join("composer.json"); // Write a composer.json where "license" is an array std::fs::write( &composer_json_path, r#"{"name": "test/project", "license": ["MIT", "Apache-2.0"]}"#, ) .unwrap(); let raw_json = std::fs::read_to_string(&composer_json_path).unwrap(); let raw_value: serde_json::Value = serde_json::from_str(&raw_json).unwrap(); let root_licenses: Vec = match raw_value.get("license") { Some(serde_json::Value::String(s)) => vec![s.clone()], Some(serde_json::Value::Array(arr)) => arr .iter() .filter_map(|v| v.as_str()) .map(|s| s.to_string()) .collect(), _ => vec![], }; assert_eq!(root_licenses, vec!["MIT", "Apache-2.0"]); } #[test] fn test_render_json_root_license_is_array() { let entries: Vec = vec![]; // Single license string becomes a one-element array in JSON output let root_licenses = ["MIT".to_string()]; let root_license_arr: Vec = root_licenses .iter() .map(|s| serde_json::Value::String(s.clone())) .collect(); let output = serde_json::json!({ "name": "test/project", "version": "1.0.0", "license": root_license_arr, "dependencies": {}, }); assert!(output["license"].is_array()); assert_eq!(output["license"][0], "MIT"); // Multiple licenses are also emitted as an array let root_licenses_multi = ["MIT".to_string(), "Apache-2.0".to_string()]; let root_license_arr_multi: Vec = root_licenses_multi .iter() .map(|s| serde_json::Value::String(s.clone())) .collect(); let output_multi = serde_json::json!({ "name": "test/project", "version": "1.0.0", "license": root_license_arr_multi, "dependencies": serde_json::json!({}), }); assert!(output_multi["license"].is_array()); assert_eq!(output_multi["license"].as_array().unwrap().len(), 2); // Ensure the helper produces consistent results for empty entries let _ = entries; } }