diff options
Diffstat (limited to 'crates/mozart-core/src')
| -rw-r--r-- | crates/mozart-core/src/lib.rs | 3 | ||||
| -rw-r--r-- | crates/mozart-core/src/package_info.rs | 124 | ||||
| -rw-r--r-- | crates/mozart-core/src/package_sorter.rs | 53 | ||||
| -rw-r--r-- | crates/mozart-core/src/repository_utils.rs | 174 |
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()); + } +} |
