aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 17:10:00 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 17:10:00 +0900
commit44fbf627c434dda2d14974d6bd0d4e941a693203 (patch)
tree179246d7f5df37945c45af46116278f39bac2556 /crates
parent261c3996805bcdfb7ff271290f3e3557dd15cea7 (diff)
downloadphp-mozart-44fbf627c434dda2d14974d6bd0d4e941a693203.tar.gz
php-mozart-44fbf627c434dda2d14974d6bd0d4e941a693203.tar.zst
php-mozart-44fbf627c434dda2d14974d6bd0d4e941a693203.zip
feat(licenses): implement licenses command with text, JSON, and summary output
Support listing dependency licenses from installed packages or lock file with --no-dev filtering, format validation, and sorted output. Includes unit tests for license extraction/counting and integration tests using temp dirs for both installed and locked package sources. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart/src/commands/licenses.rs587
1 files changed, 585 insertions, 2 deletions
diff --git a/crates/mozart/src/commands/licenses.rs b/crates/mozart/src/commands/licenses.rs
index 726f3c8..c7e79ae 100644
--- a/crates/mozart/src/commands/licenses.rs
+++ b/crates/mozart/src/commands/licenses.rs
@@ -1,4 +1,6 @@
use clap::Args;
+use std::collections::HashSet;
+use std::path::{Path, PathBuf};
#[derive(Args)]
pub struct LicensesArgs {
@@ -15,6 +17,587 @@ pub struct LicensesArgs {
pub locked: bool,
}
-pub fn execute(_args: &LicensesArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─── Data structures ────────────────────────────────────────────────────────
+
+struct LicenseEntry {
+ name: String,
+ version: String,
+ licenses: Vec<String>,
+}
+
+// ─── Main entry point ───────────────────────────────────────────────────────
+
+pub fn execute(args: &LicensesArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ // Validate format
+ let format = args.format.as_deref().unwrap_or("text");
+ if format != "text" && format != "json" && format != "summary" {
+ anyhow::bail!(
+ "Invalid format \"{}\". Supported formats: text, json, summary",
+ 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 = crate::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();
+ let root_license = root.license.clone().unwrap_or_else(|| "none".to_string());
+
+ // 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_license, &entries)?,
+ "summary" => render_summary(&entries),
+ _ => render_text(&root_name, &root_version, &root_license, &entries),
+ }
+
+ Ok(())
+}
+
+// ─── Package loading ─────────────────────────────────────────────────────────
+
+fn load_installed_licenses(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<LicenseEntry>> {
+ let vendor_dir = working_dir.join("vendor");
+ let installed = crate::installed::InstalledPackages::read(&vendor_dir)?;
+
+ let dev_names: HashSet<String> = installed
+ .dev_package_names
+ .iter()
+ .map(|n| n.to_lowercase())
+ .collect();
+
+ let mut entries: Vec<LicenseEntry> = 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<Vec<LicenseEntry>> {
+ let lock_path = working_dir.join("composer.lock");
+ if !lock_path.exists() {
+ anyhow::bail!(
+ "A valid composer.json and composer.lock file is required to run this command with --locked"
+ );
+ }
+
+ let lock = crate::lockfile::LockFile::read_from_file(&lock_path)?;
+
+ let mut all_packages: Vec<&crate::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<LicenseEntry> = 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)
+}
+
+// ─── License extraction ───────────────────────────────────────────────────────
+
+fn extract_installed_licenses(pkg: &crate::installed::InstalledPackageEntry) -> Vec<String> {
+ 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()
+}
+
+// ─── License counting ─────────────────────────────────────────────────────────
+
+fn count_licenses(entries: &[LicenseEntry]) -> Vec<(String, usize)> {
+ let mut counts: std::collections::BTreeMap<String, usize> = 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
+}
+
+// ─── Rendering ───────────────────────────────────────────────────────────────
+
+fn render_text(root_name: &str, root_version: &str, root_license: &str, entries: &[LicenseEntry]) {
+ // Print root package header
+ println!("Name: {}", root_name);
+ println!("Version: {}", root_version);
+ println!("Licenses: {}", root_license);
+ println!();
+
+ if entries.is_empty() {
+ return;
+ }
+
+ // Compute column widths
+ 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());
+
+ // Print header
+ println!(
+ "{:<nw$} {:<vw$} License",
+ "Name",
+ "Version",
+ nw = name_width,
+ vw = version_width
+ );
+ println!(
+ "{:-<nw$} {:-<vw$} -------",
+ "",
+ "",
+ nw = name_width,
+ vw = version_width
+ );
+
+ for entry in entries {
+ let license_str = if entry.licenses.is_empty() {
+ "none".to_string()
+ } else {
+ entry.licenses.join(", ")
+ };
+ println!(
+ "{:<nw$} {:<vw$} {}",
+ entry.name,
+ entry.version,
+ license_str,
+ nw = name_width,
+ vw = version_width
+ );
+ }
+}
+
+fn render_json(
+ root_name: &str,
+ root_version: &str,
+ root_license: &str,
+ entries: &[LicenseEntry],
+) -> anyhow::Result<()> {
+ let root_license_arr: Vec<serde_json::Value> = if root_license == "none" {
+ vec![]
+ } else {
+ vec![serde_json::Value::String(root_license.to_string())]
+ };
+
+ let mut dependencies: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
+ for entry in entries {
+ let license_arr: Vec<serde_json::Value> = 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,
+ });
+
+ println!("{}", serde_json::to_string_pretty(&output)?);
+ Ok(())
+}
+
+fn render_summary(entries: &[LicenseEntry]) {
+ let counts = count_licenses(entries);
+
+ if counts.is_empty() {
+ println!("No dependencies found.");
+ return;
+ }
+
+ let license_width = counts
+ .iter()
+ .map(|(l, _)| l.len())
+ .max()
+ .unwrap_or(0)
+ .max("License".len());
+
+ println!(
+ "{:<lw$} Number of dependencies",
+ "License",
+ lw = license_width
+ );
+ println!("{:-<lw$} ----------------------", "", lw = license_width);
+
+ for (license, count) in &counts {
+ println!("{:<lw$} {}", license, count, lw = license_width);
+ }
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeMap;
+
+ fn make_installed_pkg(
+ name: &str,
+ version: &str,
+ extra: BTreeMap<String, serde_json::Value>,
+ ) -> crate::installed::InstalledPackageEntry {
+ crate::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,
+ }
+ }
+
+ // ── extract_installed_licenses ────────────────────────────────────────────
+
+ #[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());
+ }
+
+ // ── count_licenses ────────────────────────────────────────────────────────
+
+ #[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<LicenseEntry> = 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));
+ }
+
+ // ── Integration tests ─────────────────────────────────────────────────────
+
+ #[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 = crate::installed::InstalledPackages::new();
+ let mut extra = BTreeMap::new();
+ extra.insert("license".to_string(), serde_json::json!(["MIT"]));
+ installed.upsert(crate::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 = crate::installed::InstalledPackages::new();
+
+ // Production package
+ let mut extra_prod = BTreeMap::new();
+ extra_prod.insert("license".to_string(), serde_json::json!(["MIT"]));
+ installed.upsert(crate::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(crate::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 crate::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(),
+ 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(),
+ 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);
+ }
}