aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-08 20:29:33 +0900
committernsfisis <nsfisis@gmail.com>2026-05-08 20:29:57 +0900
commit92fa497cc345118198508fcf948ff650e8902434 (patch)
tree5789f3d74b6ffb79dbcc8f59b012cd359caf9444 /crates
parentb286af9ffe78d50b63bf5fda7fc796ab20f2552f (diff)
downloadphp-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')
-rw-r--r--crates/mozart-core/src/lib.rs3
-rw-r--r--crates/mozart-core/src/package_info.rs124
-rw-r--r--crates/mozart-core/src/package_sorter.rs53
-rw-r--r--crates/mozart-core/src/repository_utils.rs174
-rw-r--r--crates/mozart/src/commands/dump_autoload.rs2
-rw-r--r--crates/mozart/src/commands/init.rs361
-rw-r--r--crates/mozart/src/commands/licenses.rs678
-rw-r--r--crates/mozart/src/commands/update.rs2
8 files changed, 954 insertions, 443 deletions
diff --git a/crates/mozart-core/src/lib.rs b/crates/mozart-core/src/lib.rs
index 74f3512..ab6bfe0 100644
--- a/crates/mozart-core/src/lib.rs
+++ b/crates/mozart-core/src/lib.rs
@@ -5,7 +5,10 @@ pub mod exit_code;
pub mod factory;
pub mod http;
pub mod package;
+pub mod package_info;
+pub mod package_sorter;
pub mod platform;
+pub mod repository_utils;
pub mod suggest;
pub mod validation;
pub mod version_bumper;
diff --git a/crates/mozart-core/src/package_info.rs b/crates/mozart-core/src/package_info.rs
new file mode 100644
index 0000000..d67729a
--- /dev/null
+++ b/crates/mozart-core/src/package_info.rs
@@ -0,0 +1,124 @@
+//! Mirrors `Composer\Util\PackageInfo`.
+//!
+//! The PHP class exposes two small static helpers that the `licenses`,
+//! `show`, `outdated`, and `funding` commands lean on to produce a
+//! "view source" link for a package — preferring the explicit
+//! `support.source` URL, falling back to the package's `source` URL,
+//! and finally the homepage. Empty strings are normalised to `None`.
+
+/// Minimal contract for the package fields [`view_source_url`] and
+/// [`view_source_or_homepage_url`] consult.
+pub trait PackageUrls {
+ /// `support.source` from `composer.json` (mirrors
+ /// `CompletePackageInterface::getSupport()['source']`). Returns
+ /// `None` when the support block is absent or the `source` key is
+ /// missing.
+ fn support_source(&self) -> Option<&str>;
+ /// `source.url` (mirrors `PackageInterface::getSourceUrl()`).
+ fn source_url(&self) -> Option<&str>;
+ /// `homepage` (mirrors `CompletePackageInterface::getHomepage()`).
+ fn homepage(&self) -> Option<&str>;
+}
+
+/// Mirror of `PackageInfo::getViewSourceUrl`.
+///
+/// PHP returns the support-source URL when it is set and non-empty,
+/// otherwise `getSourceUrl()`. Empty strings are treated as absent.
+pub fn view_source_url<P: PackageUrls + ?Sized>(package: &P) -> Option<String> {
+ if let Some(s) = package.support_source().filter(|s| !s.is_empty()) {
+ return Some(s.to_string());
+ }
+ package
+ .source_url()
+ .filter(|s| !s.is_empty())
+ .map(String::from)
+}
+
+/// Mirror of `PackageInfo::getViewSourceOrHomepageUrl`.
+///
+/// Falls back to the package homepage when no source URL is available.
+/// An empty homepage string is normalised to `None`, matching PHP's
+/// `if ($url === '') { return null; }` guard.
+pub fn view_source_or_homepage_url<P: PackageUrls + ?Sized>(package: &P) -> Option<String> {
+ view_source_url(package)
+ .or_else(|| package.homepage().map(String::from))
+ .filter(|s| !s.is_empty())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[derive(Default)]
+ struct P {
+ support_source: Option<String>,
+ source_url: Option<String>,
+ homepage: Option<String>,
+ }
+
+ impl PackageUrls for P {
+ 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()
+ }
+ }
+
+ #[test]
+ fn prefers_support_source() {
+ let p = P {
+ support_source: Some("https://github.com/foo/bar".to_string()),
+ source_url: Some("https://example.com/repo".to_string()),
+ ..Default::default()
+ };
+ assert_eq!(
+ view_source_url(&p).as_deref(),
+ Some("https://github.com/foo/bar")
+ );
+ }
+
+ #[test]
+ fn empty_support_source_falls_through_to_source_url() {
+ let p = P {
+ support_source: Some(String::new()),
+ source_url: Some("https://example.com/repo".to_string()),
+ ..Default::default()
+ };
+ assert_eq!(
+ view_source_url(&p).as_deref(),
+ Some("https://example.com/repo")
+ );
+ }
+
+ #[test]
+ fn falls_back_to_homepage() {
+ let p = P {
+ homepage: Some("https://example.com/".to_string()),
+ ..Default::default()
+ };
+ assert_eq!(
+ view_source_or_homepage_url(&p).as_deref(),
+ Some("https://example.com/")
+ );
+ }
+
+ #[test]
+ fn empty_homepage_is_none() {
+ let p = P {
+ homepage: Some(String::new()),
+ ..Default::default()
+ };
+ assert!(view_source_or_homepage_url(&p).is_none());
+ }
+
+ #[test]
+ fn no_urls_at_all_returns_none() {
+ let p = P::default();
+ assert!(view_source_url(&p).is_none());
+ assert!(view_source_or_homepage_url(&p).is_none());
+ }
+}
diff --git a/crates/mozart-core/src/package_sorter.rs b/crates/mozart-core/src/package_sorter.rs
new file mode 100644
index 0000000..f9871a5
--- /dev/null
+++ b/crates/mozart-core/src/package_sorter.rs
@@ -0,0 +1,53 @@
+//! Mirrors `Composer\Util\PackageSorter`.
+//!
+//! Composer's helper takes `PackageInterface[]` and sorts in place by
+//! `getName()` (the lowercase normalized name), case-sensitive `<=>`.
+//! Mozart commands hold a variety of package representations
+//! (`InstalledPackageEntry`, `LockedPackage`, `PackageData`, …); rather
+//! than force them all behind one trait, the sorter accepts a key
+//! extractor closure and is generic over the slice element type.
+
+/// Mirror of `PackageSorter::sortPackagesAlphabetically`.
+///
+/// Composer compares with `getName() <=> getName()`. `getName()` returns
+/// the normalized (lowercase) `vendor/name`, so the sort is effectively
+/// case-insensitive on the original casing but case-sensitive on the
+/// already-normalized form. Use a key extractor that returns the
+/// normalized name to match.
+pub fn sort_packages_alphabetically<T, F>(packages: &mut [T], name: F)
+where
+ F: Fn(&T) -> &str,
+{
+ packages.sort_by(|a, b| name(a).cmp(name(b)));
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[derive(Debug, PartialEq)]
+ struct P(&'static str);
+
+ #[test]
+ fn sorts_by_name_ascending() {
+ let mut v = vec![P("monolog/monolog"), P("psr/log"), P("a/b")];
+ sort_packages_alphabetically(&mut v, |p| p.0);
+ assert_eq!(v, vec![P("a/b"), P("monolog/monolog"), P("psr/log")]);
+ }
+
+ #[test]
+ fn empty_slice_is_noop() {
+ let mut v: Vec<P> = vec![];
+ sort_packages_alphabetically(&mut v, |p| p.0);
+ assert!(v.is_empty());
+ }
+
+ #[test]
+ fn case_sensitive_on_normalized_form() {
+ // Already-lowercase names — Composer stores `getName()` lowercased,
+ // so we never mix cases here, but verify ordering is plain `<=>`.
+ let mut v = vec![P("zzz/x"), P("aaa/x"), P("aaa/y")];
+ sort_packages_alphabetically(&mut v, |p| p.0);
+ assert_eq!(v, vec![P("aaa/x"), P("aaa/y"), P("zzz/x")]);
+ }
+}
diff --git a/crates/mozart-core/src/repository_utils.rs b/crates/mozart-core/src/repository_utils.rs
new file mode 100644
index 0000000..ecd5dd7
--- /dev/null
+++ b/crates/mozart-core/src/repository_utils.rs
@@ -0,0 +1,174 @@
+//! Mirrors `Composer\Repository\RepositoryUtils`.
+//!
+//! Currently ports `filterRequiredPackages` only; `flattenRepositories`
+//! has no Mozart equivalent yet because Mozart does not model nested
+//! `CompositeRepository`/`FilterRepository` structures.
+
+use std::collections::BTreeSet;
+
+/// Minimal contract for a package that can participate in the require
+/// closure walk performed by [`filter_required_packages`].
+///
+/// `package_name` is the normalized `vendor/name`. `requires` returns
+/// the require map (`name → constraint`); only the keys are consulted.
+/// `package_names` returns every name the package answers for —
+/// typically just `package_name`, but Composer's `PackageInterface::getNames()`
+/// also includes `provide`/`replace` targets. Implementations may
+/// return `None` when those auxiliary names are not yet modelled in
+/// Mozart's data layer; the walk falls back to matching on
+/// `package_name` only in that case.
+pub trait Required {
+ fn package_name(&self) -> &str;
+ fn requires(&self) -> &std::collections::BTreeMap<String, String>;
+ fn package_names(&self) -> Option<Vec<&str>> {
+ None
+ }
+}
+
+/// Mirror of `RepositoryUtils::filterRequiredPackages`.
+///
+/// Walks the require closure of `requirer_requires` against `packages`,
+/// collecting (in input order) every package that is reachable.
+/// `requirer_dev_requires`, when `Some`, is merged into the initial
+/// require set — matching the `$includeRequireDev` flag, which Composer
+/// only honours for the *initial* requirer (transitive walks always
+/// look at `getRequires()` only).
+///
+/// The returned vector preserves the order in which packages were
+/// discovered, matching PHP's `$bucket[] = $candidate;` push pattern.
+pub fn filter_required_packages<P>(
+ packages: &[P],
+ requirer_requires: &std::collections::BTreeMap<String, String>,
+ requirer_dev_requires: Option<&std::collections::BTreeMap<String, String>>,
+) -> Vec<usize>
+where
+ P: Required,
+{
+ let mut initial: BTreeSet<&str> = requirer_requires.keys().map(String::as_str).collect();
+ if let Some(dev) = requirer_dev_requires {
+ initial.extend(dev.keys().map(String::as_str));
+ }
+
+ let mut bucket: Vec<usize> = Vec::new();
+ walk(packages, &initial, &mut bucket);
+ bucket
+}
+
+fn walk<P>(packages: &[P], requires: &BTreeSet<&str>, bucket: &mut Vec<usize>)
+where
+ P: Required,
+{
+ for (idx, candidate) in packages.iter().enumerate() {
+ let names: Vec<&str> = candidate
+ .package_names()
+ .unwrap_or_else(|| vec![candidate.package_name()]);
+ let matches = names.iter().any(|n| requires.contains(n));
+ if !matches {
+ continue;
+ }
+ if bucket.contains(&idx) {
+ continue;
+ }
+ bucket.push(idx);
+ let next: BTreeSet<&str> = candidate.requires().keys().map(String::as_str).collect();
+ walk(packages, &next, bucket);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeMap;
+
+ struct Pkg {
+ name: String,
+ requires: BTreeMap<String, String>,
+ }
+
+ impl Required for Pkg {
+ fn package_name(&self) -> &str {
+ &self.name
+ }
+ fn requires(&self) -> &BTreeMap<String, String> {
+ &self.requires
+ }
+ }
+
+ fn pkg(name: &str, requires: &[&str]) -> Pkg {
+ let mut r = BTreeMap::new();
+ for n in requires {
+ r.insert(n.to_string(), "*".to_string());
+ }
+ Pkg {
+ name: name.to_string(),
+ requires: r,
+ }
+ }
+
+ fn root_requires(names: &[&str]) -> BTreeMap<String, String> {
+ let mut m = BTreeMap::new();
+ for n in names {
+ m.insert(n.to_string(), "*".to_string());
+ }
+ m
+ }
+
+ #[test]
+ fn filters_to_root_requires_only() {
+ let packages = vec![
+ pkg("a/a", &[]),
+ pkg("b/b", &[]),
+ pkg("c/c", &[]), // not required
+ ];
+ let root = root_requires(&["a/a", "b/b"]);
+ let kept = filter_required_packages(&packages, &root, None);
+ let names: Vec<&str> = kept.iter().map(|&i| packages[i].name.as_str()).collect();
+ assert_eq!(names, vec!["a/a", "b/b"]);
+ }
+
+ #[test]
+ fn walks_transitive_requires() {
+ let packages = vec![
+ pkg("a/a", &["b/b"]),
+ pkg("b/b", &["c/c"]),
+ pkg("c/c", &[]),
+ pkg("d/d", &[]), // unreachable
+ ];
+ let root = root_requires(&["a/a"]);
+ let kept = filter_required_packages(&packages, &root, None);
+ let names: Vec<&str> = kept.iter().map(|&i| packages[i].name.as_str()).collect();
+ assert_eq!(names, vec!["a/a", "b/b", "c/c"]);
+ }
+
+ #[test]
+ fn dev_requires_only_apply_at_root() {
+ let packages = vec![
+ pkg("a/a", &[]),
+ pkg("b/b", &["c/c"]),
+ pkg("c/c", &[]), // only reachable via a's dev-requires (no dev requires here)
+ pkg("d/d", &[]),
+ ];
+ let root = root_requires(&["a/a"]);
+ let dev = root_requires(&["b/b"]);
+ let kept = filter_required_packages(&packages, &root, Some(&dev));
+ let names: Vec<&str> = kept.iter().map(|&i| packages[i].name.as_str()).collect();
+ assert_eq!(names, vec!["a/a", "b/b", "c/c"]);
+ }
+
+ #[test]
+ fn handles_circular_requires() {
+ let packages = vec![pkg("a/a", &["b/b"]), pkg("b/b", &["a/a"])];
+ let root = root_requires(&["a/a"]);
+ let kept = filter_required_packages(&packages, &root, None);
+ let names: Vec<&str> = kept.iter().map(|&i| packages[i].name.as_str()).collect();
+ assert_eq!(names, vec!["a/a", "b/b"]);
+ }
+
+ #[test]
+ fn empty_requires_yields_nothing() {
+ let packages = vec![pkg("a/a", &[]), pkg("b/b", &[])];
+ let root = BTreeMap::new();
+ let kept = filter_required_packages(&packages, &root, None);
+ assert!(kept.is_empty());
+ }
+}
diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs
index 6f08b2a..0d12220 100644
--- a/crates/mozart/src/commands/dump_autoload.rs
+++ b/crates/mozart/src/commands/dump_autoload.rs
@@ -3,7 +3,7 @@ use mozart_autoload::AutoloadGeneratorExt;
use mozart_core::composer::{AutoloadDumpOptions, Composer, PlatformRequirementFilter};
use mozart_core::{console_format, console_writeln};
-#[derive(Args)]
+#[derive(Args, Default)]
pub struct DumpAutoloadArgs {
/// Optimizes PSR-0 and PSR-4 packages to be loaded with classmaps
#[arg(short, long)]
diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs
index 3378755..5d6d501 100644
--- a/crates/mozart/src/commands/init.rs
+++ b/crates/mozart/src/commands/init.rs
@@ -12,6 +12,7 @@ use std::collections::BTreeMap;
use std::io::{BufRead, Write};
use std::path::Path;
use std::process::Command;
+use std::sync::OnceLock;
#[derive(Args)]
pub struct InitArgs {
@@ -111,7 +112,12 @@ pub async fn execute(
package::write_to_file(&composer, &composer_file).context("Failed to write composer.json")?;
- // Create autoload directory if specified
+ let has_dependencies = !composer.require.is_empty() || !composer.require_dev.is_empty();
+
+ // --autoload — create the source folder. When the project has no
+ // dependencies, Composer also runs `dump-autoload` so the autoloader is
+ // immediately usable; failures are downgraded to a warning to mirror
+ // Composer's try/catch around `runDumpAutoloadCommand`.
if let Some(ref autoload) = composer.autoload {
for path in autoload.psr4.values() {
let dir = working_dir.join(path);
@@ -120,6 +126,13 @@ pub async fn execute(
.with_context(|| format!("Failed to create directory {}", dir.display()))?;
}
}
+
+ if !has_dependencies {
+ let dump_args = super::dump_autoload::DumpAutoloadArgs::default();
+ if let Err(e) = super::dump_autoload::execute(&dump_args, cli, console).await {
+ console.error(&format!("Could not run dump-autoload. ({e})"));
+ }
+ }
}
// Offer to add /vendor/ to .gitignore
@@ -134,6 +147,22 @@ pub async fn execute(
}
}
+ // Run `composer update` after init when the new project has dependencies
+ // and the user confirms — Composer's L190-193.
+ if console.interactive
+ && has_dependencies
+ && console.confirm(&console_format!(
+ "Would you like to install dependencies now [<comment>yes</comment>]?"
+ ))
+ {
+ let update_args = super::update::UpdateArgs::default();
+ if let Err(e) = super::update::execute(&update_args, cli, console).await {
+ console.error(&format!(
+ "Could not update dependencies. Run `composer update` to see more information. ({e})"
+ ));
+ }
+ }
+
// Show autoload info
if let Some(ref autoload) = composer.autoload
&& let Some((ns, path)) = autoload.psr4.iter().next()
@@ -278,24 +307,32 @@ async fn build_interactive(
}
};
- // Minimum Stability
+ // Minimum Stability — Composer's askAndValidate loops until valid (the
+ // validator throws InvalidArgumentException, Symfony's QuestionHelper
+ // catches it and re-prompts when maxAttempts is null).
let default_stability = args.stability.clone().unwrap_or_default();
- let stability_input = console.ask(
- &console_format!(
- "Minimum Stability [<comment>{}</comment>]",
- &default_stability
- ),
- &default_stability,
- );
+ let stability_input = console
+ .ask_validated(
+ &console_format!(
+ "Minimum Stability [<comment>{}</comment>]",
+ &default_stability
+ ),
+ &default_stability,
+ |val| {
+ if val.is_empty() || validation::validate_stability(val) {
+ Ok(())
+ } else {
+ Err(format!(
+ "Invalid minimum stability \"{val}\". Must be empty or one of: dev, alpha, beta, rc, stable"
+ ))
+ }
+ },
+ )
+ .map_err(|e| anyhow::anyhow!(e))?;
let minimum_stability = if stability_input.is_empty() {
None
- } else if validation::validate_stability(&stability_input) {
- Some(stability_input.to_lowercase())
} else {
- console.error(&format!(
- "Invalid minimum stability \"{stability_input}\". Using empty."
- ));
- None
+ Some(stability_input.to_lowercase())
};
// Package Type
@@ -313,28 +350,27 @@ async fn build_interactive(
Some(type_input)
};
- // License
+ // License — Composer prompts once, then validates outside the prompt and
+ // throws on invalid (no retry loop). See InitCommand::interact L364-372.
let default_license = args
.license
.clone()
.or_else(|| std::env::var("COMPOSER_DEFAULT_LICENSE").ok())
.unwrap_or_default();
- let license = loop {
- let license_input = console.ask(
- &console_format!("License [<comment>{}</comment>]", &default_license),
- &default_license,
+ let license_input = console.ask(
+ &console_format!("License [<comment>{}</comment>]", &default_license),
+ &default_license,
+ );
+ let license = if license_input.is_empty() {
+ None
+ } else if validation::validate_license(&license_input)
+ || license_input.eq_ignore_ascii_case("proprietary")
+ {
+ Some(license_input)
+ } else {
+ bail!(
+ "Invalid license provided: {license_input}. Only SPDX license identifiers (https://spdx.org/licenses/) or \"proprietary\" are accepted."
);
- if license_input.is_empty() {
- break None;
- } else if validation::validate_license(&license_input)
- || license_input.eq_ignore_ascii_case("proprietary")
- {
- break Some(license_input);
- } else {
- console.error(&format!(
- "Invalid license provided: {license_input}. Only SPDX license identifiers (https://spdx.org/licenses/) or \"proprietary\" are accepted."
- ));
- }
};
// Dependencies
@@ -347,17 +383,25 @@ async fn build_interactive(
console.info(&console_format!("<info>Define your dependencies.</info>"));
console.info("");
+ // Composer (InitCommand::interact L389-403): if --require was passed,
+ // skip the confirmation; otherwise ask before entering the discovery loop.
let mut require = parse_requirements(&args.require)?;
- let interactive_require = interactive_search_packages(
- "require",
- &require,
- preferred_stability,
- repo_cache,
- console,
- )
- .await?;
- for (name, constraint) in interactive_require {
- require.insert(name, constraint);
+ if !require.is_empty()
+ || console.confirm(&console_format!(
+ "Would you like to define your dependencies (require) interactively [<comment>yes</comment>]?"
+ ))
+ {
+ let interactive_require = interactive_search_packages(
+ "require",
+ &require,
+ preferred_stability,
+ repo_cache,
+ console,
+ )
+ .await?;
+ for (name, constraint) in interactive_require {
+ require.insert(name, constraint);
+ }
}
// Dev Dependencies
@@ -368,42 +412,55 @@ async fn build_interactive(
console.info("");
let mut require_dev = parse_requirements(&args.require_dev)?;
- let all_required: BTreeMap<String, String> = require
- .iter()
- .chain(require_dev.iter())
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect();
- let interactive_dev = interactive_search_packages(
- "require-dev",
- &all_required,
- preferred_stability,
- repo_cache,
- console,
- )
- .await?;
- for (name, constraint) in interactive_dev {
- require_dev.insert(name, constraint);
+ if !require_dev.is_empty()
+ || console.confirm(&console_format!(
+ "Would you like to define your dev dependencies (require-dev) interactively [<comment>yes</comment>]?"
+ ))
+ {
+ let all_required: BTreeMap<String, String> = require
+ .iter()
+ .chain(require_dev.iter())
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect();
+ let interactive_dev = interactive_search_packages(
+ "require-dev",
+ &all_required,
+ preferred_stability,
+ repo_cache,
+ console,
+ )
+ .await?;
+ for (name, constraint) in interactive_dev {
+ require_dev.insert(name, constraint);
+ }
}
- // PSR-4 Autoload
+ // PSR-4 Autoload — Composer validates with regex `^[^/][A-Za-z0-9\-_/]+/$`
+ // via askAndValidate (loops until valid). `n`/`no` skips.
let default_autoload = args.autoload.clone().unwrap_or_else(|| "src/".to_string());
let namespace = validation::namespace_from_package_name(&name).unwrap_or_default();
- let autoload_input = console.ask(
- &console_format!(
- "Add PSR-4 autoload mapping? Maps namespace \"{namespace}\" to the entered relative path. [<comment>{}</comment>, n to skip]",
+ let autoload_input = console
+ .ask_validated(
+ &console_format!(
+ "Add PSR-4 autoload mapping? Maps namespace \"{namespace}\" to the entered relative path. [<comment>{}</comment>, n to skip]",
+ &default_autoload,
+ ),
&default_autoload,
- ),
- &default_autoload,
- );
+ |val| {
+ if val == "n" || val == "no" || validation::validate_autoload_path(val) {
+ Ok(())
+ } else {
+ Err(format!(
+ "The src folder name \"{val}\" is invalid. Please add a relative path with tailing forward slash. [A-Za-z0-9_-/]+/"
+ ))
+ }
+ },
+ )
+ .map_err(|e| anyhow::anyhow!(e))?;
let autoload = if autoload_input == "n" || autoload_input == "no" {
None
} else {
- let path = if autoload_input.is_empty() {
- default_autoload
- } else {
- autoload_input
- };
- build_autoload(&path, &name)
+ build_autoload(&autoload_input, &name)
};
let repositories = parse_repositories(&args.repository)?;
@@ -651,21 +708,33 @@ fn get_default_author() -> Option<String> {
}
}
-fn get_git_config_value(key: &str) -> Option<String> {
- Command::new("git")
- .args(["config", "--get", key])
- .output()
- .ok()
- .and_then(|output| {
- if output.status.success() {
- String::from_utf8(output.stdout)
- .ok()
- .map(|s| s.trim().to_string())
- .filter(|s| !s.is_empty())
- } else {
- None
+/// `git config -l` parsed into `key=value` pairs and cached for the life of
+/// the process. Mirrors Composer's `InitCommand::getGitConfig`, which runs
+/// the command once and memoises the parsed map.
+fn get_git_config() -> &'static BTreeMap<String, String> {
+ static GIT_CONFIG: OnceLock<BTreeMap<String, String>> = OnceLock::new();
+ GIT_CONFIG.get_or_init(|| {
+ let mut map = BTreeMap::new();
+ let Ok(output) = Command::new("git").args(["config", "-l"]).output() else {
+ return map;
+ };
+ if !output.status.success() {
+ return map;
+ }
+ let Ok(text) = String::from_utf8(output.stdout) else {
+ return map;
+ };
+ for line in text.lines() {
+ if let Some((key, value)) = line.split_once('=') {
+ map.insert(key.to_string(), value.to_string());
}
- })
+ }
+ map
+ })
+}
+
+fn get_git_config_value(key: &str) -> Option<String> {
+ get_git_config().get(key).cloned().filter(|v| !v.is_empty())
}
fn parse_requirements(reqs: &[String]) -> anyhow::Result<BTreeMap<String, String>> {
@@ -685,39 +754,58 @@ fn build_autoload(path: &str, package_name: &str) -> Option<RawAutoload> {
Some(RawAutoload { psr4 })
}
+/// Parse `--repository` arguments. Mirrors
+/// `Composer\Repository\RepositoryFactory::configFromString`:
+///
+/// * `http(s)://...` → `{type: composer, url: $repo}`.
+/// * `{...}` → JSON object parsed verbatim into a repository config.
+/// * `*.json` file path → composer-type repo file (deferred; not yet supported).
+/// * anything else → reject with an error matching Composer's wording.
fn parse_repositories(repos: &[String]) -> anyhow::Result<Vec<RawRepository>> {
let mut result = Vec::new();
for repo in repos {
- if repo.starts_with('{') {
- // JSON format
- let parsed: serde_json::Value =
- serde_json::from_str(repo).context("Invalid repository JSON")?;
- let repo_type = parsed["type"].as_str().unwrap_or("vcs").to_string();
- let url = parsed["url"]
- .as_str()
- .ok_or_else(|| anyhow::anyhow!("Repository JSON must contain a 'url' field"))?
- .to_string();
- result.push(RawRepository {
- repo_type,
- url: Some(url),
- package: None,
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- });
+ let parsed: serde_json::Value = if repo.starts_with("http") {
+ serde_json::json!({ "type": "composer", "url": repo })
+ } else if repo.starts_with('{') {
+ serde_json::from_str(repo).context("Invalid repository JSON")?
} else {
- // Plain URL
- result.push(RawRepository {
- repo_type: "vcs".to_string(),
- url: Some(repo.clone()),
- package: None,
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- });
- }
+ bail!(
+ "Invalid repository url ({repo}) given. Has to be a .json file, an http url or a JSON object."
+ );
+ };
+
+ let repo_type = parsed
+ .get("type")
+ .and_then(|v| v.as_str())
+ .ok_or_else(|| anyhow::anyhow!("Repository JSON must contain a 'type' field"))?
+ .to_string();
+ let url = parsed
+ .get("url")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ let package = parsed.get("package").cloned();
+ let only = parsed.get("only").and_then(|v| v.as_array()).map(|a| {
+ a.iter()
+ .filter_map(|x| x.as_str().map(String::from))
+ .collect()
+ });
+ let exclude = parsed.get("exclude").and_then(|v| v.as_array()).map(|a| {
+ a.iter()
+ .filter_map(|x| x.as_str().map(String::from))
+ .collect()
+ });
+ let canonical = parsed.get("canonical").and_then(|v| v.as_bool());
+ let security_advisories = parsed.get("security-advisories").cloned();
+
+ result.push(RawRepository {
+ repo_type,
+ url,
+ package,
+ only,
+ exclude,
+ canonical,
+ security_advisories,
+ });
}
Ok(result)
}
@@ -746,3 +834,52 @@ fn add_vendor_ignore(gitignore_path: &Path) -> anyhow::Result<()> {
std::fs::write(gitignore_path, contents)?;
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_repositories_http_url_yields_composer_type() {
+ let repos = parse_repositories(&["https://repo.example.com".to_string()]).unwrap();
+ assert_eq!(repos.len(), 1);
+ assert_eq!(repos[0].repo_type, "composer");
+ assert_eq!(repos[0].url.as_deref(), Some("https://repo.example.com"));
+ }
+
+ #[test]
+ fn parse_repositories_http_scheme_also_matches() {
+ let repos = parse_repositories(&["http://example.com".to_string()]).unwrap();
+ assert_eq!(repos[0].repo_type, "composer");
+ }
+
+ #[test]
+ fn parse_repositories_json_object_preserved() {
+ let repos = parse_repositories(&[
+ r#"{"type":"vcs","url":"https://github.com/acme/repo"}"#.to_string()
+ ])
+ .unwrap();
+ assert_eq!(repos[0].repo_type, "vcs");
+ assert_eq!(
+ repos[0].url.as_deref(),
+ Some("https://github.com/acme/repo")
+ );
+ }
+
+ #[test]
+ fn parse_repositories_unknown_form_is_error() {
+ let err = parse_repositories(&["not-a-url-or-json".to_string()]).unwrap_err();
+ assert!(
+ err.to_string()
+ .contains("Has to be a .json file, an http url or a JSON object"),
+ "{err}",
+ );
+ }
+
+ #[test]
+ fn parse_repositories_json_without_type_is_error() {
+ let err =
+ parse_repositories(&[r#"{"url":"https://example.com"}"#.to_string()]).unwrap_err();
+ assert!(err.to_string().contains("'type'"), "{err}");
+ }
+}
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);
}
}
diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs
index 2e93a0c..92a998d 100644
--- a/crates/mozart/src/commands/update.rs
+++ b/crates/mozart/src/commands/update.rs
@@ -7,7 +7,7 @@ use mozart_registry::resolver::{
self, LockedPackageInfo, PlatformConfig, ResolveRequest, ResolvedPackage,
};
-#[derive(Args)]
+#[derive(Args, Default)]
pub struct UpdateArgs {
/// Package(s) to update
pub packages: Vec<String>,