aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src
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/mozart-core/src
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/mozart-core/src')
-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
4 files changed, 354 insertions, 0 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());
+ }
+}