diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-08 22:14:41 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-08 22:14:41 +0900 |
| commit | bfbb8bd5260dfffb2c1f7fc8935142b26a9c4039 (patch) | |
| tree | 556d8024f17d3b7ff60bc478575ba3f931d466f8 /crates/mozart-core/src/installer | |
| parent | d0d05f14a4d1b36f517077ffdaa4b335c812190f (diff) | |
| download | php-mozart-bfbb8bd5260dfffb2c1f7fc8935142b26a9c4039.tar.gz php-mozart-bfbb8bd5260dfffb2c1f7fc8935142b26a9c4039.tar.zst php-mozart-bfbb8bd5260dfffb2c1f7fc8935142b26a9c4039.zip | |
fix(check-platform-reqs): align with Composer's CheckPlatformReqsCommand flow
Mirror `CheckPlatformReqsCommand::execute` end-to-end: build an
`InstalledRepoLite` from lock/installed plus the root and the real
PlatformRepository, ksort the combined `$requires`, and run the
candidate matching loop with `findPackagesWithReplacersAndProviders`
so an installed package that `provide`s or `replace`s a platform name
(e.g. `symfony/polyfill-mbstring` providing `ext-mbstring`) is now
recognised as satisfying the requirement.
Fixes the JSON output schema to match Composer:
`failed_requirement` is the `{source, type, target, constraint}`
object (or null), `provider` is the bare "provided by …" string (or
null), and `status` is the unwrapped `success`/`failed`/`missing`.
Also switches `--format` to a clap `value_parser` and replaces the
"No installed packages found" hard error with Composer's warn-then-
proceed path so an empty lock yields `[]` and exit 0.
Adds `mozart_core::installer::InstalledCandidate` plus
`InstalledRepoLite::add_candidate` /
`find_with_replacers_and_providers` as the shared substrate for
future commands (`depends`, `prohibits`, `audit`) that need the same
provider/replacer index.
Diffstat (limited to 'crates/mozart-core/src/installer')
| -rw-r--r-- | crates/mozart-core/src/installer/installed_repo.rs | 194 | ||||
| -rw-r--r-- | crates/mozart-core/src/installer/mod.rs | 8 | ||||
| -rw-r--r-- | crates/mozart-core/src/installer/suggested_packages_reporter.rs | 406 |
3 files changed, 608 insertions, 0 deletions
diff --git a/crates/mozart-core/src/installer/installed_repo.rs b/crates/mozart-core/src/installer/installed_repo.rs new file mode 100644 index 0000000..8361158 --- /dev/null +++ b/crates/mozart-core/src/installer/installed_repo.rs @@ -0,0 +1,194 @@ +//! Lightweight stand-in for `Composer\Repository\InstalledRepository`. +//! +//! Composer's `InstalledRepository` is a composite over `LockArrayRepository`, +//! `InstalledRepositoryInterface`, `RootPackageRepository`, and +//! `PlatformRepository`. Mozart does not (yet) expose a unified repository +//! abstraction, so this struct is the smallest layer we need to support the +//! handful of commands that drive their behavior off +//! `findPackagesWithReplacersAndProviders` (currently `check-platform-reqs` +//! and `suggests`; later candidates: `depends`/`prohibits`, `audit`). +//! +//! The struct serves two roles: +//! +//! - As a lower-cased name set: callers `insert(name)` whatever they want +//! visible to `contains` / suggestion-filter logic. +//! - As a candidate index: callers `add_candidate(InstalledCandidate)` and +//! then resolve a require name to the candidate(s) that satisfy it directly +//! or through a `provide` / `replace` link. + +use indexmap::IndexSet; +use std::collections::BTreeMap; + +/// One installed package, in the shape `findPackagesWithReplacersAndProviders` +/// needs. Mirrors the fields of `Composer\Package\PackageInterface` that the +/// PHP implementation reads — name, version, provides, replaces. +#[derive(Debug, Clone)] +pub struct InstalledCandidate { + /// Lower-cased package name, used for matching. + pub name: String, + /// Original-case package name, used in user-facing output. + pub pretty_name: String, + /// Normalized version (what the constraint matcher consumes). + pub version: String, + /// Original-case version, used in user-facing output. + pub pretty_version: String, + /// `provide` map: target package name → constraint string. + pub provides: BTreeMap<String, String>, + /// `replace` map: target package name → constraint string. + pub replaces: BTreeMap<String, String>, +} + +#[derive(Debug, Clone, Default)] +pub struct InstalledRepoLite { + /// Lower-cased names of every package, plus every `provide`/`replace` + /// target that any candidate exposes. `contains` queries this set. + pub names: IndexSet<String>, + /// Full candidate records, in insertion order. + pub candidates: Vec<InstalledCandidate>, +} + +impl InstalledRepoLite { + pub fn new() -> Self { + Self::default() + } + + pub fn insert(&mut self, name: &str) { + self.names.insert(name.to_lowercase()); + } + + pub fn contains(&self, name: &str) -> bool { + self.names.contains(&name.to_lowercase()) + } + + /// Add a full candidate record. Also inserts the candidate's own name and + /// every `provide` / `replace` target into the names set so `contains` + /// keeps reflecting all installed virtuals. + pub fn add_candidate(&mut self, candidate: InstalledCandidate) { + self.names.insert(candidate.name.clone()); + for target in candidate.provides.keys().chain(candidate.replaces.keys()) { + self.names.insert(target.to_lowercase()); + } + self.candidates.push(candidate); + } + + /// Mirrors `Composer\Repository\InstalledRepository::findPackagesWithReplacersAndProviders` + /// without the optional constraint filter — callers in + /// `check-platform-reqs` apply their own per-link constraint check after + /// they have the candidate list. Returns each candidate at most once. + pub fn find_with_replacers_and_providers(&self, require: &str) -> Vec<&InstalledCandidate> { + let needle = require.to_lowercase(); + let mut matches: Vec<&InstalledCandidate> = Vec::new(); + for candidate in &self.candidates { + if candidate.name == needle { + matches.push(candidate); + continue; + } + let provides_or_replaces = candidate + .provides + .keys() + .chain(candidate.replaces.keys()) + .any(|target| target.to_lowercase() == needle); + if provides_or_replaces { + matches.push(candidate); + } + } + matches + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_candidate(name: &str, version: &str) -> InstalledCandidate { + InstalledCandidate { + name: name.to_lowercase(), + pretty_name: name.to_string(), + version: version.to_string(), + pretty_version: version.to_string(), + provides: BTreeMap::new(), + replaces: BTreeMap::new(), + } + } + + #[test] + fn insert_and_contains_lowercase() { + let mut repo = InstalledRepoLite::new(); + repo.insert("Vendor/Pkg"); + assert!(repo.contains("vendor/pkg")); + assert!(repo.contains("VENDOR/PKG")); + } + + #[test] + fn add_candidate_registers_name_and_virtuals() { + let mut c = make_candidate("vendor/poly", "1.0.0"); + c.provides.insert("ext-mbstring".into(), "1.0".into()); + c.replaces.insert("ext-iconv".into(), "*".into()); + + let mut repo = InstalledRepoLite::new(); + repo.add_candidate(c); + + assert!(repo.contains("vendor/poly")); + assert!(repo.contains("ext-mbstring")); + assert!(repo.contains("ext-iconv")); + } + + #[test] + fn find_returns_direct_match() { + let mut repo = InstalledRepoLite::new(); + repo.add_candidate(make_candidate("php", "8.2.1")); + let hits = repo.find_with_replacers_and_providers("php"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].name, "php"); + } + + #[test] + fn find_returns_provider() { + let mut c = make_candidate("symfony/polyfill-mbstring", "1.30.0"); + c.provides.insert("ext-mbstring".into(), "*".into()); + + let mut repo = InstalledRepoLite::new(); + repo.add_candidate(c); + + let hits = repo.find_with_replacers_and_providers("ext-mbstring"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].name, "symfony/polyfill-mbstring"); + } + + #[test] + fn find_returns_replacer() { + let mut c = make_candidate("vendor/forklift", "2.0.0"); + c.replaces.insert("vendor/legacy".into(), "1.*".into()); + + let mut repo = InstalledRepoLite::new(); + repo.add_candidate(c); + + let hits = repo.find_with_replacers_and_providers("vendor/legacy"); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].name, "vendor/forklift"); + } + + #[test] + fn find_returns_empty_when_unknown() { + let mut repo = InstalledRepoLite::new(); + repo.add_candidate(make_candidate("php", "8.2.1")); + assert!( + repo.find_with_replacers_and_providers("ext-foobar") + .is_empty() + ); + } + + #[test] + fn find_includes_each_candidate_at_most_once() { + let mut c = make_candidate("vendor/poly", "1.0.0"); + // Same target listed in both maps — should still only return one hit. + c.provides.insert("ext-x".into(), "*".into()); + c.replaces.insert("ext-x".into(), "*".into()); + + let mut repo = InstalledRepoLite::new(); + repo.add_candidate(c); + + let hits = repo.find_with_replacers_and_providers("ext-x"); + assert_eq!(hits.len(), 1); + } +} diff --git a/crates/mozart-core/src/installer/mod.rs b/crates/mozart-core/src/installer/mod.rs new file mode 100644 index 0000000..8572627 --- /dev/null +++ b/crates/mozart-core/src/installer/mod.rs @@ -0,0 +1,8 @@ +pub mod installed_repo; +pub mod suggested_packages_reporter; + +pub use installed_repo::{InstalledCandidate, InstalledRepoLite}; +pub use suggested_packages_reporter::{ + HasSuggests, MODE_BY_PACKAGE, MODE_BY_SUGGESTION, MODE_LIST, RootInfo, + SuggestedPackagesReporter, Suggestion, +}; diff --git a/crates/mozart-core/src/installer/suggested_packages_reporter.rs b/crates/mozart-core/src/installer/suggested_packages_reporter.rs new file mode 100644 index 0000000..2a356fc --- /dev/null +++ b/crates/mozart-core/src/installer/suggested_packages_reporter.rs @@ -0,0 +1,406 @@ +//! Port of `Composer\Installer\SuggestedPackagesReporter`. +//! +//! Collects suggestions from packages and renders them grouped by package, +//! by suggestion, or as a flat list. Mirrors the bitfield-mode API that +//! Composer's reporter exposes so other entry points (install/update) can +//! emit a minimalistic post-install hint with the same code path. + +use crate::console::{Console, Verbosity}; +use crate::console_format; +use crate::installer::installed_repo::InstalledRepoLite; +use indexmap::IndexSet; +use std::collections::BTreeMap; + +pub const MODE_LIST: u32 = 1; +pub const MODE_BY_PACKAGE: u32 = 2; +pub const MODE_BY_SUGGESTION: u32 = 4; + +/// One suggestion record. Mirrors `array{source, target, reason}` in PHP. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Suggestion { + pub source: String, + pub target: String, + pub reason: String, +} + +/// Anything that can yield a (pretty name, suggest map) for the reporter. +/// +/// Mirrors Composer's `PackageInterface::getPrettyName()` + `getSuggests()`. +/// Implemented for `RawPackageData` here, and for the registry crate's +/// `LockedPackage` / `InstalledPackageEntry` next to those types. +pub trait HasSuggests { + fn pretty_name(&self) -> &str; + /// Iterator yielding `(target, reason)` pairs. + fn suggests(&self) -> Vec<(String, String)>; +} + +impl HasSuggests for crate::package::RawPackageData { + fn pretty_name(&self) -> &str { + &self.name + } + + fn suggests(&self) -> Vec<(String, String)> { + let Some(val) = self.extra_fields.get("suggest") else { + return Vec::new(); + }; + let Some(obj) = val.as_object() else { + return Vec::new(); + }; + obj.iter() + .filter_map(|(target, reason)| reason.as_str().map(|r| (target.clone(), r.to_string()))) + .collect() + } +} + +/// Stand-in for Composer's `$onlyDependentsOf` package. +/// +/// Holds the root package's name plus its direct require / require-dev +/// targets. The reporter uses this to filter to only direct dependents' +/// suggestions when no explicit packages or `--all` flag was given. +#[derive(Debug, Clone, Default)] +pub struct RootInfo { + pub name: String, + pub direct_deps: IndexSet<String>, +} + +impl RootInfo { + /// Lower-cased filter set: root name + all direct deps. + fn source_filter(&self) -> IndexSet<String> { + let mut set = self.direct_deps.clone(); + if !self.name.is_empty() { + set.insert(self.name.to_lowercase()); + } + set + } +} + +/// Collects and renders package suggestions. +/// +/// Construct with [`SuggestedPackagesReporter::new`], feed packages via +/// [`Self::add_suggestions_from_package`] or [`Self::add_package`], then +/// render with [`Self::output`] (or [`Self::output_minimalistic`] for the +/// install/update one-liner). +pub struct SuggestedPackagesReporter<'a> { + suggested_packages: Vec<Suggestion>, + console: &'a Console, +} + +impl<'a> SuggestedPackagesReporter<'a> { + pub fn new(console: &'a Console) -> Self { + Self { + suggested_packages: Vec::new(), + console, + } + } + + pub fn packages(&self) -> &[Suggestion] { + &self.suggested_packages + } + + pub fn add_package(&mut self, source: String, target: String, reason: String) -> &mut Self { + self.suggested_packages.push(Suggestion { + source, + target, + reason, + }); + self + } + + pub fn add_suggestions_from_package<P: HasSuggests + ?Sized>( + &mut self, + package: &P, + ) -> &mut Self { + let source = package.pretty_name().to_string(); + for (target, reason) in package.suggests() { + self.add_package(source.clone(), target, reason); + } + self + } + + /// Render the collected suggestions according to `mode`. + /// + /// `installed_repo` — when set, suggestions whose target is already + /// installed are suppressed. + /// `only_dependents_of` — when set, only suggestions whose source is the + /// root package itself or one of its direct require/require-dev targets + /// are shown; an "additional suggestions can be shown with --all" hint + /// is emitted at the end if any were filtered out. + pub fn output( + &self, + mode: u32, + installed_repo: Option<&InstalledRepoLite>, + only_dependents_of: Option<&RootInfo>, + ) { + let suggestions = self.get_filtered_suggestions(installed_repo, only_dependents_of); + + // Build (sorted by source/target) maps, last-reason-wins on duplicates. + let mut suggesters: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new(); + let mut suggested: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new(); + for s in &suggestions { + suggesters + .entry(s.source.clone()) + .or_default() + .insert(s.target.clone(), s.reason.clone()); + suggested + .entry(s.target.clone()) + .or_default() + .insert(s.source.clone(), s.reason.clone()); + } + + if mode & MODE_LIST != 0 { + for name in suggested.keys() { + self.write_line(&console_format!("<info>{}</info>", name)); + } + return; + } + + if mode & MODE_BY_PACKAGE != 0 { + for (suggester, suggestions) in &suggesters { + self.write_line(&console_format!( + "<comment>{}</comment> suggests:", + suggester + )); + for (suggestion, reason) in suggestions { + self.write_suggestion_info(suggestion, reason); + } + self.write_line(""); + } + } + + if mode & MODE_BY_SUGGESTION != 0 { + if mode & MODE_BY_PACKAGE != 0 { + self.write_line(&"-".repeat(78)); + } + for (suggestion, suggesters) in &suggested { + self.write_line(&console_format!( + "<comment>{}</comment> is suggested by:", + suggestion + )); + for (suggester, reason) in suggesters { + self.write_suggestion_comment(suggester, reason); + } + self.write_line(""); + } + } + + if only_dependents_of.is_some() { + let all_suggestions = self.get_filtered_suggestions(installed_repo, None); + let diff = all_suggestions.len().saturating_sub(suggestions.len()); + if diff > 0 { + self.write_line(&format!( + "{} by transitive dependencies can be shown with {}", + console_format!("<info>{} additional suggestions</info>", diff), + console_format!("<info>--all</info>"), + )); + } + } + } + + /// One-line stderr hint emitted by `install` / `update` after the run. + pub fn output_minimalistic( + &self, + installed_repo: Option<&InstalledRepoLite>, + only_dependents_of: Option<&RootInfo>, + ) { + let suggestions = self.get_filtered_suggestions(installed_repo, only_dependents_of); + if !suggestions.is_empty() { + self.console.write( + &console_format!( + "<info>{} package suggestions were added by new dependencies, use `composer suggest` to see details.</info>", + suggestions.len() + ), + Verbosity::Normal, + ); + } + } + + fn write_line(&self, msg: &str) { + if self.console.verbosity >= Verbosity::Normal { + println!("{msg}"); + } + } + + fn write_suggestion_info(&self, target: &str, reason: &str) { + let reason = Self::escape_output(reason); + if reason.is_empty() { + self.write_line(&console_format!(" - <info>{}</info>", target)); + } else { + self.write_line(&console_format!(" - <info>{}</info>: {}", target, reason)); + } + } + + fn write_suggestion_comment(&self, source: &str, reason: &str) { + let reason = Self::escape_output(reason); + if reason.is_empty() { + self.write_line(&console_format!(" - <comment>{}</comment>", source)); + } else { + self.write_line(&console_format!( + " - <comment>{}</comment>: {}", + source, + reason + )); + } + } + + fn get_filtered_suggestions<'b>( + &'b self, + installed_repo: Option<&InstalledRepoLite>, + only_dependents_of: Option<&RootInfo>, + ) -> Vec<&'b Suggestion> { + let source_filter = only_dependents_of.map(|r| r.source_filter()); + + self.suggested_packages + .iter() + .filter(|s| { + if let Some(repo) = installed_repo + && repo.contains(&s.target) + { + return false; + } + if let Some(ref filter) = source_filter + && !filter.is_empty() + && !filter.contains(&s.source.to_lowercase()) + { + return false; + } + true + }) + .collect() + } + + /// Mirrors Composer's `escapeOutput` — strips control characters and + /// converts newlines to spaces. Mozart's `console_format!` is a + /// compile-time proc-macro so runtime `<...>` substrings don't get + /// re-interpreted as tags; the explicit `<` backslash-escape that + /// Composer adds via `OutputFormatter::escape` is a no-op for us. + fn escape_output(s: &str) -> String { + Self::remove_control_characters(s) + } + + fn remove_control_characters(s: &str) -> String { + s.replace('\n', " ") + .chars() + .filter(|c| !c.is_control()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::console::Console; + + fn console() -> Console { + Console::new(0, false, false, true, true) + } + + fn make_pkg(name: &'static str, suggests: &[(&'static str, &'static str)]) -> StubPkg { + StubPkg { + name, + suggests: suggests + .iter() + .map(|(t, r)| (t.to_string(), r.to_string())) + .collect(), + } + } + + struct StubPkg { + name: &'static str, + suggests: Vec<(String, String)>, + } + + impl HasSuggests for StubPkg { + fn pretty_name(&self) -> &str { + self.name + } + fn suggests(&self) -> Vec<(String, String)> { + self.suggests.clone() + } + } + + #[test] + fn add_package_appends_record() { + let console = console(); + let mut reporter = SuggestedPackagesReporter::new(&console); + reporter.add_package("a/a".into(), "ext-intl".into(), "for i18n".into()); + assert_eq!(reporter.packages().len(), 1); + assert_eq!(reporter.packages()[0].source, "a/a"); + assert_eq!(reporter.packages()[0].target, "ext-intl"); + assert_eq!(reporter.packages()[0].reason, "for i18n"); + } + + #[test] + fn add_suggestions_from_package_uses_pretty_name() { + let console = console(); + let mut reporter = SuggestedPackagesReporter::new(&console); + let pkg = make_pkg( + "Vendor/Pkg", + &[("ext-intl", "for i18n"), ("ext-redis", "for cache")], + ); + reporter.add_suggestions_from_package(&pkg); + assert_eq!(reporter.packages().len(), 2); + assert!(reporter.packages().iter().all(|s| s.source == "Vendor/Pkg")); + } + + #[test] + fn filter_skips_already_installed_targets() { + let console = console(); + let mut reporter = SuggestedPackagesReporter::new(&console); + reporter.add_package("a/a".into(), "ext-intl".into(), "r1".into()); + reporter.add_package("a/a".into(), "ext-redis".into(), "r2".into()); + + let mut installed = InstalledRepoLite::new(); + installed.insert("ext-intl"); + + let filtered = reporter.get_filtered_suggestions(Some(&installed), None); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].target, "ext-redis"); + } + + #[test] + fn filter_only_dependents_of() { + let console = console(); + let mut reporter = SuggestedPackagesReporter::new(&console); + reporter.add_package("vendor/direct".into(), "ext-x".into(), "".into()); + reporter.add_package("vendor/transitive".into(), "ext-y".into(), "".into()); + + let root = RootInfo { + name: "my/root".into(), + direct_deps: ["vendor/direct".to_string()].into_iter().collect(), + }; + + let filtered = reporter.get_filtered_suggestions(None, Some(&root)); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].source, "vendor/direct"); + } + + #[test] + fn filter_only_dependents_of_includes_root_itself() { + let console = console(); + let mut reporter = SuggestedPackagesReporter::new(&console); + reporter.add_package("my/root".into(), "ext-x".into(), "".into()); + reporter.add_package("vendor/transitive".into(), "ext-y".into(), "".into()); + + let root = RootInfo { + name: "my/root".into(), + direct_deps: IndexSet::new(), + }; + + let filtered = reporter.get_filtered_suggestions(None, Some(&root)); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].source, "my/root"); + } + + #[test] + fn remove_control_characters_strips_cntrl_and_newline() { + let s = SuggestedPackagesReporter::remove_control_characters("foo\nbar\x07baz"); + assert_eq!(s, "foo bar".to_string() + "baz"); + } + + #[test] + fn mode_constants_match_composer() { + assert_eq!(MODE_LIST, 1); + assert_eq!(MODE_BY_PACKAGE, 2); + assert_eq!(MODE_BY_SUGGESTION, 4); + } +} |
