diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-08 20:29:33 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-08 20:29:57 +0900 |
| commit | 92fa497cc345118198508fcf948ff650e8902434 (patch) | |
| tree | 5789f3d74b6ffb79dbcc8f59b012cd359caf9444 /crates/mozart/src/commands/licenses.rs | |
| parent | b286af9ffe78d50b63bf5fda7fc796ab20f2552f (diff) | |
| download | php-mozart-92fa497cc345118198508fcf948ff650e8902434.tar.gz php-mozart-92fa497cc345118198508fcf948ff650e8902434.tar.zst php-mozart-92fa497cc345118198508fcf948ff650e8902434.zip | |
fix(licenses): align with Composer's LicensesCommand pipeline
Drive the command from Composer::require() and route the
(installed | locked) branch through the ported PackageSorter,
RepositoryUtils::filterRequiredPackages, and PackageInfo helpers
in mozart-core. --no-dev for installed packages now filters via
root.require closure instead of dev_package_names membership;
text output annotates the name cell with an OSC 8 hyperlink to
the view-source/homepage URL; summary ties resolve in first-seen
order via IndexMap + stable sort_by_key(Reverse(count)) to mirror
PHP's arsort().
Diffstat (limited to 'crates/mozart/src/commands/licenses.rs')
| -rw-r--r-- | crates/mozart/src/commands/licenses.rs | 678 |
1 files changed, 349 insertions, 329 deletions
diff --git a/crates/mozart/src/commands/licenses.rs b/crates/mozart/src/commands/licenses.rs index 1574d79..468fde7 100644 --- a/crates/mozart/src/commands/licenses.rs +++ b/crates/mozart/src/commands/licenses.rs @@ -1,40 +1,76 @@ use clap::Args; -use indexmap::IndexSet; +use indexmap::IndexMap; +use mozart_core::composer::Composer; use mozart_core::console::Console; +use mozart_core::console::hyperlink; use mozart_core::console_format; use mozart_core::console_writeln; +use mozart_core::package_info; +use mozart_core::package_info::PackageUrls; +use mozart_core::package_sorter::sort_packages_alphabetically; +use mozart_core::repository_utils; +use mozart_core::repository_utils::Required; use serde::Serialize; -use std::path::Path; +use std::collections::BTreeMap; #[derive(Args)] pub struct LicensesArgs { - /// Output format (text, json, summary) + /// Format of the output: text, json or summary #[arg(short, long)] pub format: Option<String>, - /// Disables listing of require-dev packages + /// Disables search in require-dev packages. #[arg(long)] pub no_dev: bool, - /// List packages from the lock file + /// Shows licenses from the lock file instead of what's currently installed. #[arg(long)] pub locked: bool, } +/// Unified view over an installed or locked package, carrying the +/// fields the `licenses` command renders. Mirrors the slice of +/// `CompletePackageInterface` consumed by `LicensesCommand` — name, +/// version, license, requires, and the URL bits used to build a +/// `<href>` link in the text output. struct LicenseEntry { + pretty_name: String, name: String, version: String, licenses: Vec<String>, + requires: BTreeMap<String, String>, + support_source: Option<String>, + source_url: Option<String>, + homepage: Option<String>, +} + +impl Required for LicenseEntry { + fn package_name(&self) -> &str { + &self.name + } + fn requires(&self) -> &BTreeMap<String, String> { + &self.requires + } +} + +impl PackageUrls for LicenseEntry { + fn support_source(&self) -> Option<&str> { + self.support_source.as_deref() + } + fn source_url(&self) -> Option<&str> { + self.source_url.as_deref() + } + fn homepage(&self) -> Option<&str> { + self.homepage.as_deref() + } } pub async fn execute( args: &LicensesArgs, cli: &super::Cli, - console: &mozart_core::console::Console, + 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!( @@ -43,150 +79,190 @@ pub async fn execute( ); } - // 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 composer = Composer::require(&working_dir)?; - 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(); + // TODO(plugins): dispatch CommandEvent for `licenses`. - // Parse root license as Vec<String>: composer.json allows either a string or an array. - let root_licenses: Vec<String> = { - // 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![], - } - }; + let root = composer.package(); - // Load dependency entries - let entries = if args.locked { - load_locked_licenses(&working_dir, args.no_dev)? + // RawPackageData stores `license` as `Option<String>` only, so we + // re-parse the composer.json to also accept the array form Composer + // recognises via `RootPackageLoader`'s `(array) $config['license']` + // coercion. Track widening `RawPackageData::license` separately. + let root_licenses = read_root_licenses(&working_dir.join("composer.json"))?; + let root_pretty_name = root.name.clone(); + let root_version = root + .version + .clone() + .unwrap_or_else(|| "No version set".to_string()); + + let mut entries = if args.locked { + load_locked_entries(&working_dir, args.no_dev)? } else { - load_installed_licenses(&working_dir, args.no_dev)? + load_installed_entries(&working_dir, &root.require, args.no_dev)? }; - // Render output + sort_packages_alphabetically(&mut entries, |e| e.name.as_str()); + match format { - "json" => render_json(&root_name, &root_version, &root_licenses, &entries, console)?, + "json" => render_json( + &root_pretty_name, + &root_version, + &root_licenses, + &entries, + console, + )?, "summary" => render_summary(&entries, console), - _ => render_text(&root_name, &root_version, &root_licenses, &entries, console), + _ => render_text( + &root_pretty_name, + &root_version, + &root_licenses, + &entries, + console, + ), } Ok(()) } -fn load_installed_licenses(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<LicenseEntry>> { +fn read_root_licenses(composer_json_path: &std::path::Path) -> anyhow::Result<Vec<String>> { + let raw = std::fs::read_to_string(composer_json_path)?; + let value: serde_json::Value = serde_json::from_str(&raw)?; + Ok(match 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(String::from) + .collect(), + _ => Vec::new(), + }) +} + +fn load_installed_entries( + working_dir: &std::path::Path, + root_requires: &BTreeMap<String, String>, + no_dev: bool, +) -> anyhow::Result<Vec<LicenseEntry>> { let vendor_dir = working_dir.join("vendor"); let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?; - let dev_names: IndexSet<String> = installed - .dev_package_names - .iter() - .map(|n| n.to_lowercase()) - .collect(); + let entries: Vec<LicenseEntry> = installed.packages.iter().map(installed_to_entry).collect(); - let mut entries: Vec<LicenseEntry> = installed - .packages - .iter() - .filter(|p| { - if no_dev && dev_names.contains(&p.name.to_lowercase()) { - return false; + if no_dev { + // Mirrors Composer's `--no-dev` branch in `LicensesCommand`: + // `RepositoryUtils::filterRequiredPackages($repo->getPackages(), $root)` + // — root's `require` only, transitively. Dev-only requires of + // the root, and packages reachable only through them, drop out. + let kept = repository_utils::filter_required_packages(&entries, root_requires, None); + let mut out = Vec::with_capacity(kept.len()); + // We can't `entries[idx].clone()` without Clone; rebuild from + // owned `entries` by index in two passes. + let mut by_idx: Vec<Option<LicenseEntry>> = entries.into_iter().map(Some).collect(); + for idx in kept { + if let Some(e) = by_idx[idx].take() { + out.push(e); } - 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) + } + Ok(out) + } else { + Ok(entries) + } } -fn load_locked_licenses(working_dir: &Path, no_dev: bool) -> anyhow::Result<Vec<LicenseEntry>> { +fn load_locked_entries( + working_dir: &std::path::Path, + no_dev: bool, +) -> anyhow::Result<Vec<LicenseEntry>> { 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(); - + // Mirrors `Locker::getLockedRepository(!$noDev)`: the prod-only call + // returns just `packages`, the dev-included call returns the union. + let mut entries: Vec<LicenseEntry> = lock.packages.iter().map(locked_to_entry).collect(); if !no_dev && let Some(ref pkgs_dev) = lock.packages_dev { - all_packages.extend(pkgs_dev.iter()); + entries.extend(pkgs_dev.iter().map(locked_to_entry)); } - - 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) } -fn extract_installed_licenses( - pkg: &mozart_registry::installed::InstalledPackageEntry, -) -> Vec<String> { - pkg.extra_fields +fn installed_to_entry(pkg: &mozart_registry::installed::InstalledPackageEntry) -> LicenseEntry { + let licenses = 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()) + .map(String::from) .collect() }) - .unwrap_or_default() -} + .unwrap_or_default(); -fn count_licenses(entries: &[LicenseEntry]) -> Vec<(String, usize)> { - let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new(); + let requires = pkg + .extra_fields + .get("require") + .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(); - 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 support_source = pkg + .support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(String::from); + + let source_url = pkg + .source + .as_ref() + .and_then(|s| s.get("url")) + .and_then(|s| s.as_str()) + .map(String::from); + + LicenseEntry { + pretty_name: pkg.name.clone(), + name: pkg.name.to_lowercase(), + version: pkg.version.clone(), + licenses, + requires, + support_source, + source_url, + homepage: pkg.homepage.clone(), } +} - 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 locked_to_entry(pkg: &mozart_registry::lockfile::LockedPackage) -> LicenseEntry { + let support_source = pkg + .support + .as_ref() + .and_then(|s| s.get("source")) + .and_then(|s| s.as_str()) + .map(String::from); + + LicenseEntry { + pretty_name: pkg.name.clone(), + name: pkg.name.to_lowercase(), + version: pkg.version.clone(), + licenses: pkg.license.clone().unwrap_or_default(), + requires: pkg.require.clone(), + support_source, + source_url: pkg.source.as_ref().map(|s| s.url.clone()), + homepage: pkg.homepage.clone(), + } } fn render_text( - root_name: &str, + root_pretty_name: &str, root_version: &str, root_licenses: &[String], entries: &[LicenseEntry], @@ -199,7 +275,7 @@ fn render_text( }; console_writeln!( console, - &console_format!("Name: <comment>{root_name}</comment>"), + &console_format!("Name: <comment>{root_pretty_name}</comment>"), ); console_writeln!( console, @@ -218,7 +294,7 @@ fn render_text( let name_width = entries .iter() - .map(|e| e.name.len()) + .map(|e| e.pretty_name.len()) .max() .unwrap_or(0) .max("Name".len()); @@ -246,14 +322,18 @@ fn render_text( } else { entry.licenses.join(", ") }; + let padded_name = format!("{:<nw$}", entry.pretty_name, nw = name_width); + let name_cell = match package_info::view_source_or_homepage_url(entry) { + Some(url) => hyperlink(&url, &padded_name, console.decorated), + None => padded_name, + }; console_writeln!( console, &format!( - "{:<nw$} {:<vw$} {}", - entry.name, + "{} {:<vw$} {}", + name_cell, entry.version, license_str, - nw = name_width, vw = version_width ), ); @@ -261,7 +341,7 @@ fn render_text( } fn render_json( - root_name: &str, + root_pretty_name: &str, root_version: &str, root_licenses: &[String], entries: &[LicenseEntry], @@ -280,7 +360,7 @@ fn render_json( .map(|l| serde_json::Value::String(l.clone())) .collect(); dependencies.insert( - entry.name.clone(), + entry.pretty_name.clone(), serde_json::json!({ "version": entry.version, "license": license_arr, @@ -289,7 +369,7 @@ fn render_json( } let output = serde_json::json!({ - "name": root_name, + "name": root_pretty_name, "version": root_version, "license": root_license_arr, "dependencies": dependencies, @@ -304,7 +384,7 @@ fn render_json( } fn render_summary(entries: &[LicenseEntry], console: &Console) { - let counts = count_licenses(entries); + let counts = tally_licenses(entries); if counts.is_empty() { console_writeln!(console, "No dependencies found."); @@ -356,132 +436,133 @@ fn render_summary(entries: &[LicenseEntry], console: &Console) { console_writeln!(console, &format!(" {} {}", border_col1, border_col2),); } +/// Mirror of `LicensesCommand::execute`'s `summary` accumulator. +/// +/// PHP iterates the (already alphabetically sorted) packages, increments +/// `$usedLicenses[$name]++`, then `arsort()` — descending by count, +/// ties resolved in the array's existing order (which is first-seen). +/// `IndexMap` preserves first-seen order; sorting it with a stable +/// `sort_by` reproduces PHP's tie-break exactly. +fn tally_licenses(entries: &[LicenseEntry]) -> Vec<(String, usize)> { + let mut counts: IndexMap<String, usize> = IndexMap::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(); + result.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + result +} + #[cfg(test)] mod tests { use super::*; use std::collections::BTreeMap; - fn make_installed_pkg( - name: &str, - version: &str, - extra: BTreeMap<String, serde_json::Value>, - ) -> 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![], + fn entry(name: &str, licenses: &[&str]) -> LicenseEntry { + LicenseEntry { + pretty_name: name.to_string(), + name: name.to_lowercase(), + version: "1.0.0".to_string(), + licenses: licenses.iter().map(|s| s.to_string()).collect(), + requires: BTreeMap::new(), + support_source: None, + source_url: None, homepage: None, - support: None, - 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"]), + fn tally_licenses_orders_by_count_then_first_seen() { + // First MIT entry comes before Apache-2.0; tie-break must keep + // MIT first when their counts collide. + let entries = vec![ + entry("a/a", &["MIT"]), + entry("b/b", &["Apache-2.0"]), + entry("c/c", &["BSD-3-Clause"]), + ]; + let counts = tally_licenses(&entries); + // All three at count 1 — input order preserved. + assert_eq!( + counts, + vec![ + ("MIT".to_string(), 1), + ("Apache-2.0".to_string(), 1), + ("BSD-3-Clause".to_string(), 1), + ] ); - 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() { + fn tally_licenses_count_descending() { 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()], - }, + entry("a/a", &["Apache-2.0"]), + entry("b/b", &["MIT"]), + entry("c/c", &["MIT"]), ]; - - let counts = count_licenses(&entries); - assert_eq!(counts.len(), 2); - // MIT should come first (count=2) + let counts = tally_licenses(&entries); 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()); + fn tally_licenses_empty() { + assert!(tally_licenses(&[]).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)); + fn tally_licenses_no_license_counts_as_none() { + let entries = vec![entry("a/a", &[])]; + let counts = tally_licenses(&entries); + assert_eq!(counts, vec![("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"); + fn read_root_licenses_string_form() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("composer.json"); + std::fs::write(&path, r#"{"name": "test/p", "license": "MIT"}"#).unwrap(); + assert_eq!(read_root_licenses(&path).unwrap(), vec!["MIT"]); + } - // Write composer.json (required by execute, but not needed for load_installed_licenses) + #[test] + fn read_root_licenses_array_form() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("composer.json"); std::fs::write( - working_dir.join("composer.json"), - r#"{"name": "test/project"}"#, + &path, + r#"{"name": "test/p", "license": ["MIT", "Apache-2.0"]}"#, ) .unwrap(); + assert_eq!( + read_root_licenses(&path).unwrap(), + vec!["MIT", "Apache-2.0"] + ); + } - // Build installed packages - let mut installed = mozart_registry::installed::InstalledPackages::new(); + #[test] + fn read_root_licenses_absent() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("composer.json"); + std::fs::write(&path, r#"{"name": "test/p"}"#).unwrap(); + assert!(read_root_licenses(&path).unwrap().is_empty()); + } + + #[test] + fn installed_to_entry_extracts_require_and_license() { + use mozart_registry::installed::InstalledPackageEntry; let mut extra = BTreeMap::new(); extra.insert("license".to_string(), serde_json::json!(["MIT"])); - installed.upsert(mozart_registry::installed::InstalledPackageEntry { + extra.insert( + "require".to_string(), + serde_json::json!({"psr/log": "^1.0"}), + ); + let pkg = InstalledPackageEntry { name: "monolog/monolog".to_string(), version: "3.0.0".to_string(), version_normalized: None, @@ -494,39 +575,61 @@ mod tests { homepage: None, support: None, 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"]); + }; + let e = installed_to_entry(&pkg); + assert_eq!(e.licenses, vec!["MIT"]); + assert_eq!(e.requires.get("psr/log").map(String::as_str), Some("^1.0")); } #[test] - fn test_load_installed_licenses_no_dev() { - use tempfile::tempdir; + fn installed_to_entry_pulls_support_source_and_source_url() { + use mozart_registry::installed::InstalledPackageEntry; + let pkg = InstalledPackageEntry { + name: "vendor/pkg".to_string(), + version: "1.0.0".to_string(), + version_normalized: None, + source: Some(serde_json::json!({"type": "git", "url": "https://example.com/repo.git"})), + dist: None, + package_type: None, + install_path: None, + autoload: None, + aliases: vec![], + homepage: Some("https://example.com/".to_string()), + support: Some(serde_json::json!({"source": "https://github.com/v/p"})), + extra_fields: BTreeMap::new(), + }; + let e = installed_to_entry(&pkg); + assert_eq!(e.support_source.as_deref(), Some("https://github.com/v/p")); + assert_eq!( + e.source_url.as_deref(), + Some("https://example.com/repo.git") + ); + assert_eq!(e.homepage.as_deref(), Some("https://example.com/")); + // PackageInfo helpers should pick support source first. + assert_eq!( + package_info::view_source_or_homepage_url(&e).as_deref(), + Some("https://github.com/v/p"), + ); + } - let dir = tempdir().unwrap(); + #[test] + fn no_dev_filters_to_root_require_closure() { + // Set up: root requires a/a only. b/b is in installed but not + // reachable; should be dropped under --no-dev. + let dir = tempfile::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"}"#, + r#"{"name": "test/project", "require": {"a/a": "*"}}"#, ) .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(), + name: "a/a".to_string(), + version: "1.0.0".to_string(), version_normalized: None, source: None, dist: None, @@ -536,15 +639,11 @@ mod tests { aliases: vec![], homepage: None, support: None, - extra_fields: extra_prod, + extra_fields: BTreeMap::new(), }); - - // 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(), + name: "b/b".to_string(), + version: "1.0.0".to_string(), version_normalized: None, source: None, dist: None, @@ -554,41 +653,35 @@ mod tests { aliases: vec![], homepage: None, support: None, - extra_fields: extra_dev, + extra_fields: BTreeMap::new(), }); - 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"); + let mut root_req = BTreeMap::new(); + root_req.insert("a/a".to_string(), "*".to_string()); + + let kept = load_installed_entries(working_dir, &root_req, true).unwrap(); + let names: Vec<&str> = kept.iter().map(|e| e.name.as_str()).collect(); + assert_eq!(names, vec!["a/a"]); - // Without --no-dev: both packages - let entries_all = load_installed_licenses(working_dir, false).unwrap(); - assert_eq!(entries_all.len(), 2); + // Without --no-dev: both packages are listed. + let all = load_installed_entries(working_dir, &root_req, false).unwrap(); + assert_eq!(all.len(), 2); } #[test] - fn test_load_locked_licenses_basic() { + fn locked_no_dev_drops_packages_dev() { use mozart_registry::lockfile::{LockFile, LockedPackage}; - use tempfile::tempdir; - - let dir = tempdir().unwrap(); + let dir = tempfile::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(), + content_hash: "abc".to_string(), packages: vec![LockedPackage { name: "psr/log".to_string(), version: "3.0.0".to_string(), @@ -648,87 +741,14 @@ mod tests { 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<String> = 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<LicenseEntry> = vec![]; - - // Single license string becomes a one-element array in JSON output - let root_licenses = ["MIT".to_string()]; - let root_license_arr: Vec<serde_json::Value> = 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<serde_json::Value> = 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); + let prod = load_locked_entries(working_dir, true).unwrap(); + assert_eq!(prod.len(), 1); + assert_eq!(prod[0].name, "psr/log"); - // Ensure the helper produces consistent results for empty entries - let _ = entries; + let all = load_locked_entries(working_dir, false).unwrap(); + assert_eq!(all.len(), 2); } } |
