From 24cc697a9cd0dcac854359d65b8265f02f483b72 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 11 May 2026 19:45:17 +0900 Subject: chore(lint): add Ruby linter scripts and apply rules Adds scripts/lint with linters for mod.rs naming, contiguous use blocks, use-as aliasing, sorted Cargo dependencies, std::collections maps, and workspace dependency requirements. Renames mod.rs files, reorders use statements, drops unnecessary import aliases, and sorts Cargo.toml entries to satisfy the new rules. --- crates/mozart-core/src/config.rs | 3 +- crates/mozart-core/src/config_source.rs | 3 +- crates/mozart-core/src/config_validator.rs | 16 +- crates/mozart-core/src/installer.rs | 8 + crates/mozart-core/src/installer/mod.rs | 8 - .../src/package/archiver/archive_manager.rs | 3 +- crates/mozart-core/src/repository/downloader.rs | 2 +- .../src/repository/installer_executor.rs | 347 ++++++++++++++++++++ .../src/repository/installer_executor/mod.rs | 348 --------------------- .../installer_executor/trace_recorder.rs | 3 +- .../mozart-core/src/repository/path_repository.rs | 13 +- crates/mozart-core/src/repository/repository.rs | 318 +++++++++++++++++++ .../mozart-core/src/repository/repository/mod.rs | 319 ------------------- crates/mozart-core/src/vcs/process.rs | 3 +- crates/mozart-core/src/vcs/util.rs | 3 + crates/mozart-core/src/vcs/util/mod.rs | 3 - 16 files changed, 695 insertions(+), 705 deletions(-) create mode 100644 crates/mozart-core/src/installer.rs delete mode 100644 crates/mozart-core/src/installer/mod.rs create mode 100644 crates/mozart-core/src/repository/installer_executor.rs delete mode 100644 crates/mozart-core/src/repository/installer_executor/mod.rs create mode 100644 crates/mozart-core/src/repository/repository.rs delete mode 100644 crates/mozart-core/src/repository/repository/mod.rs create mode 100644 crates/mozart-core/src/vcs/util.rs delete mode 100644 crates/mozart-core/src/vcs/util/mod.rs (limited to 'crates/mozart-core') diff --git a/crates/mozart-core/src/config.rs b/crates/mozart-core/src/config.rs index 58d1d17..1fbbb41 100644 --- a/crates/mozart-core/src/config.rs +++ b/crates/mozart-core/src/config.rs @@ -5,12 +5,11 @@ //! known properties. Unknown properties are captured in the `extra` map so //! that round-tripping through serde is lossless. +use crate::composer::composer_home; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use crate::composer::composer_home; - /// Parse a size string like "300MiB", "1GB", "512k", or a plain integer string /// into a byte count. Mirrors Composer's `Config::get('cache-files-maxsize')`. fn parse_size_bytes(s: &str) -> Option { diff --git a/crates/mozart-core/src/config_source.rs b/crates/mozart-core/src/config_source.rs index 984007a..42d2d6f 100644 --- a/crates/mozart-core/src/config_source.rs +++ b/crates/mozart-core/src/config_source.rs @@ -1,6 +1,5 @@ -use std::path::{Path, PathBuf}; - use anyhow::anyhow; +use std::path::{Path, PathBuf}; pub struct JsonConfigSource { path: PathBuf, diff --git a/crates/mozart-core/src/config_validator.rs b/crates/mozart-core/src/config_validator.rs index dbed651..85cc538 100644 --- a/crates/mozart-core/src/config_validator.rs +++ b/crates/mozart-core/src/config_validator.rs @@ -5,13 +5,11 @@ //! Composer's: `ValidateCommand` and `DiagnoseCommand` each `new //! ConfigValidator(...)`; neither depends on the other. +use crate::validation; +use regex::Regex; use std::collections::HashSet; use std::sync::LazyLock; -use regex::Regex; - -use crate::validation as v; - static DEPRECATED_GPL_OR_LATER_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^[AL]?GPL-[123](\.[01])?\+$").unwrap()); @@ -113,7 +111,7 @@ fn check_name(obj: &serde_json::Map, result: &mut Val if name.chars().any(|c| c.is_ascii_uppercase()) { let suggested = name .split('/') - .map(v::sanitize_package_name_component) + .map(validation::sanitize_package_name_component) .collect::>() .join("/"); result.publish_errors.push(format!( @@ -122,7 +120,7 @@ fn check_name(obj: &serde_json::Map, result: &mut Val )); } - if !name.is_empty() && !v::validate_package_name(name) && !name.contains('/') { + if !name.is_empty() && !validation::validate_package_name(name) && !name.contains('/') { result.errors.push(format!( "The name \"{name}\" is invalid, it should be in the format \"vendor/package\"." )); @@ -224,11 +222,11 @@ fn check_license(obj: &serde_json::Map, result: &mut continue; } let to_validate = license.replace("proprietary", "MIT"); - if v::validate_license(&to_validate) { + if validation::validate_license(&to_validate) { continue; } let quoted = serde_json::to_string(license).unwrap_or_else(|_| format!("\"{license}\"")); - if v::validate_license(to_validate.trim()) { + if validation::validate_license(to_validate.trim()) { result.warnings.push(format!( "License {quoted} must not contain extra spaces, make sure to trim it." )); @@ -461,7 +459,7 @@ fn check_minimum_stability( result: &mut ValidationResult, ) { if let Some(stability) = obj.get("minimum-stability").and_then(|v| v.as_str()) - && !v::validate_stability(stability) + && !validation::validate_stability(stability) { result.errors.push(format!( "The minimum-stability \"{stability}\" is invalid. \ diff --git a/crates/mozart-core/src/installer.rs b/crates/mozart-core/src/installer.rs new file mode 100644 index 0000000..8572627 --- /dev/null +++ b/crates/mozart-core/src/installer.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/mod.rs b/crates/mozart-core/src/installer/mod.rs deleted file mode 100644 index 8572627..0000000 --- a/crates/mozart-core/src/installer/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -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/package/archiver/archive_manager.rs b/crates/mozart-core/src/package/archiver/archive_manager.rs index b4f8e27..14497b2 100644 --- a/crates/mozart-core/src/package/archiver/archive_manager.rs +++ b/crates/mozart-core/src/package/archiver/archive_manager.rs @@ -1,9 +1,8 @@ -use crate::downloader::DownloadManager; - use super::{ ArchiveFormat, collect_archivable_files, create_archive, generate_archive_filename, parse_composer_excludes, parse_gitattributes, parse_gitignore_pattern, self_exclusion_patterns, }; +use crate::downloader::DownloadManager; use std::path::{Path, PathBuf}; /// A package to be archived. diff --git a/crates/mozart-core/src/repository/downloader.rs b/crates/mozart-core/src/repository/downloader.rs index f2e33a7..711a678 100644 --- a/crates/mozart-core/src/repository/downloader.rs +++ b/crates/mozart-core/src/repository/downloader.rs @@ -350,7 +350,7 @@ pub async fn install_package( #[cfg(test)] mod tests { use super::*; - use std::io::Write as IoWrite; + use std::io::Write as _; use tempfile::tempdir; /// Build a minimal zip archive in memory. diff --git a/crates/mozart-core/src/repository/installer_executor.rs b/crates/mozart-core/src/repository/installer_executor.rs new file mode 100644 index 0000000..1cb26d2 --- /dev/null +++ b/crates/mozart-core/src/repository/installer_executor.rs @@ -0,0 +1,347 @@ +//! Installation execution abstraction. +//! +//! Mirrors `Composer\Installer\InstallationManager`: the per-operation +//! side-effect surface (download, extract, remove from vendor/) lives behind +//! a trait so test code can substitute a recording-only implementation +//! (Composer's `InstallationManagerMock`) without going anywhere near the +//! filesystem or the network. +//! +//! The orchestration loop (computing operations from lock vs installed, +//! emitting console messages, writing `installed.json`, generating the +//! autoloader) stays in the caller. The executor is purely the verb — +//! "install this package" / "uninstall this package" — so test traces match +//! Composer's `(string) $operation` byte-for-byte without the executor +//! having to also reproduce console formatting. + +use super::installed::InstalledPackageEntry; +use super::lockfile::{LockAlias, LockedPackage}; +use std::path::PathBuf; + +pub mod filesystem; +pub mod trace_recorder; +pub mod transaction; + +pub use filesystem::FilesystemExecutor; +pub use trace_recorder::TraceRecorderExecutor; +pub use transaction::{ + Action, StaleInstalledAlias, compute_operations, compute_stale_installed_aliases, + locked_to_installed_entry, previously_installed_alias_versions, +}; + +/// One install or update operation handed to [`InstallerExecutor::install_package`]. +#[derive(Debug, Clone, Copy)] +pub enum PackageOperation<'a> { + /// First-time install. The whole package directory is created from + /// `package.dist`/`package.source`. + Install { package: &'a LockedPackage }, + /// Replace an existing install with a new version. `from_version` is the + /// pretty version that was installed before (no reference suffix — + /// drives the upgrade-vs-downgrade direction). `from_full_pretty` / + /// `to_full_pretty` are the formatted display strings used verbatim in + /// the trace output; the caller renders them via + /// [`format_update_pretty_versions`] so the SOURCE_REF / DIST_REF mode + /// switch from Composer's `UpdateOperation::format` lands on both sides. + Update { + from_version: &'a str, + from_full_pretty: &'a str, + to_full_pretty: &'a str, + package: &'a LockedPackage, + }, + /// Mark an alias of a real package as installed. No filesystem effects — + /// only the trace recorder needs this. Mirrors Composer's + /// `MarkAliasInstalledOperation`. + MarkAliasInstalled { + /// The alias entry from `composer.lock`'s `aliases[]` block. Carries + /// pretty + normalized alias version and the target's pretty version. + alias: &'a LockAlias, + /// The target package the alias points at — used to source the + /// reference suffix for the trace line. + target: &'a LockedPackage, + }, + /// Mark a previously-installed alias as uninstalled. No filesystem + /// effects — only the trace recorder cares. Mirrors Composer's + /// `MarkAliasUninstalledOperation`. Composer derives the AliasPackage + /// from the previous installed.json entries (via `extra.branch-alias`), + /// then emits this when the alias is no longer in the result. Caller + /// pre-renders the display strings so this variant doesn't need to know + /// how to spelunk the entry. + MarkAliasUninstalled { + /// Package name (e.g. `a/a`) used as both the alias's name and the + /// target's name on the trace line. + name: &'a str, + /// Alias's full-pretty form (alias pretty version plus reference + /// suffix), e.g. `1.0.x-dev master`. + alias_full: &'a str, + /// Target's full-pretty form, e.g. `dev-master master`. + target_full: &'a str, + }, +} + +impl<'a> PackageOperation<'a> { + pub fn package(&self) -> Option<&'a LockedPackage> { + match self { + PackageOperation::Install { package } | PackageOperation::Update { package, .. } => { + Some(package) + } + PackageOperation::MarkAliasInstalled { .. } + | PackageOperation::MarkAliasUninstalled { .. } => None, + } + } +} + +/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for a `LockedPackage`. +/// +/// For dev-stability versions backed by a git/hg source, append the reference +/// (truncated to 7 chars when it looks like a 40-char sha1). Otherwise return +/// the pretty version unchanged. +pub fn format_full_pretty_version(pkg: &LockedPackage) -> String { + format_full_pretty_with_pretty(&pkg.version, pkg) +} + +/// Same as [`format_full_pretty_version`] but lets the caller supply an +/// alternate pretty version (used by `MarkAliasInstalled` so the alias's +/// `3.2.x-dev` text is rendered with the *target's* reference). +pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage) -> String { + let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref()); + let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); + let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str()); + format_full_pretty_with_refs( + pretty_version, + &pkg.version, + source_ref, + dist_ref, + source_type, + ) +} + +/// Render an alias's full pretty version: the alias's own pretty form for +/// the visible text, the alias's *normalized* version for the dev-stability +/// gate, and the target package's source/dist references for the suffix. +/// Mirrors `AliasPackage::getFullPrettyVersion`, where the alias decides on +/// its own whether to append a reference based on its own stability — so a +/// stable alias like `1.0.0` skips the suffix even when the target is a dev +/// branch. +pub fn format_full_pretty_alias( + alias_pretty: &str, + alias_version: &str, + target: &LockedPackage, +) -> String { + let source_ref = target.source.as_ref().and_then(|s| s.reference.as_deref()); + let dist_ref = target.dist.as_ref().and_then(|d| d.reference.as_deref()); + let source_type = target.source.as_ref().map(|s| s.source_type.as_str()); + format_full_pretty_with_refs( + alias_pretty, + alias_version, + source_ref, + dist_ref, + source_type, + ) +} + +/// Same as [`format_full_pretty_version_for_installed`] but lets the caller +/// supply an alternate pretty version. Used when emitting +/// `MarkAliasUninstalled`: the alias's `1.0.x-dev` text needs to be rendered +/// with the *target installed entry's* reference suffix. +pub fn format_full_pretty_with_pretty_for_installed( + pretty_version: &str, + entry: &InstalledPackageEntry, +) -> String { + let source_ref = entry + .source + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let dist_ref = entry + .dist + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let source_type = entry + .source + .as_ref() + .and_then(|v| v.get("type")) + .and_then(|v| v.as_str()); + format_full_pretty_with_refs( + pretty_version, + &entry.version, + source_ref, + dist_ref, + source_type, + ) +} + +/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for an +/// `InstalledPackageEntry`. Same display rules as +/// [`format_full_pretty_version`] but pulls source/dist info out of the +/// installed.json `source`/`dist` JSON values. +pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -> String { + format_full_pretty_with_pretty_for_installed(&entry.version, entry) +} + +/// Render the from/to display strings for an update trace line, mirroring +/// Composer's `UpdateOperation::format`. Defaults to `DISPLAY_SOURCE_REF_IF_DEV`, +/// then if both sides render identically: +/// +/// - source references differ → re-render in `DISPLAY_SOURCE_REF` mode, +/// - else dist references differ → re-render in `DISPLAY_DIST_REF` mode. +/// +/// Without the switch, two same-version-different-reference packages would +/// produce a useless `pkg (X => X)` trace line. +pub fn format_update_pretty_versions( + from_entry: &InstalledPackageEntry, + to_pkg: &LockedPackage, +) -> (String, String) { + let from_default = format_full_pretty_version_for_installed(from_entry); + let to_default = format_full_pretty_version(to_pkg); + if from_default != to_default { + return (from_default, to_default); + } + + let from_source_ref = from_entry + .source + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let from_source_type = from_entry + .source + .as_ref() + .and_then(|v| v.get("type")) + .and_then(|v| v.as_str()); + let to_source_ref = to_pkg.source.as_ref().and_then(|s| s.reference.as_deref()); + let to_source_type = to_pkg.source.as_ref().map(|s| s.source_type.as_str()); + + if from_source_ref != to_source_ref { + return ( + format_with_explicit_reference(&from_entry.version, from_source_ref, from_source_type), + format_with_explicit_reference(&to_pkg.version, to_source_ref, to_source_type), + ); + } + + let from_dist_ref = from_entry + .dist + .as_ref() + .and_then(|v| v.get("reference")) + .and_then(|v| v.as_str()); + let to_dist_ref = to_pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); + + if from_dist_ref != to_dist_ref { + return ( + format_with_explicit_reference(&from_entry.version, from_dist_ref, from_source_type), + format_with_explicit_reference(&to_pkg.version, to_dist_ref, to_source_type), + ); + } + + (from_default, to_default) +} + +/// Render `pretty_version` with an explicitly chosen reference, mirroring +/// Composer's `BasePackage::getFullPrettyVersion` with `DISPLAY_SOURCE_REF` +/// or `DISPLAY_DIST_REF`: skip the dev-stability gate, just truncate sha1 +/// references and concatenate. A `None` reference falls back to the bare +/// pretty version. +fn format_with_explicit_reference( + pretty_version: &str, + reference: Option<&str>, + source_type: Option<&str>, +) -> String { + let Some(reference) = reference else { + return pretty_version.to_string(); + }; + if matches!(source_type, Some("svn")) { + return format!("{} {}", pretty_version, reference); + } + if reference.len() == 40 { + return format!("{} {}", pretty_version, &reference[..7]); + } + format!("{} {}", pretty_version, reference) +} + +/// Core of `BasePackage::getFullPrettyVersion()` factored over raw +/// fields so both [`LockedPackage`] and [`InstalledPackageEntry`] can share +/// the rendering logic. `version` drives the dev-stability check; the result +/// is `pretty_version` plus a reference suffix when the package is a dev +/// branch backed by git/hg (with sha1 references truncated to 7 chars). +fn format_full_pretty_with_refs( + pretty_version: &str, + version: &str, + source_ref: Option<&str>, + dist_ref: Option<&str>, + source_type: Option<&str>, +) -> String { + let is_dev = mozart_semver::Version::parse(version) + .map(|v| matches!(v.pre_release.as_deref(), Some("dev")) || v.is_dev_branch) + .unwrap_or(false); + if !is_dev { + return pretty_version.to_string(); + } + // Composer falls back to dist reference only when no source type is set + // (or the package isn't git/hg — in which case the dev display is skipped + // entirely above). + let reference = source_ref.or(match source_type { + Some("git") | Some("hg") => None, + _ => dist_ref, + }); + let Some(reference) = reference else { + return pretty_version.to_string(); + }; + if matches!(source_type, Some("git") | Some("hg")) && reference.len() == 40 { + format!("{} {}", pretty_version, &reference[..7]) + } else if matches!(source_type, Some("svn")) { + // svn references are revision numbers, never truncated + format!("{} {}", pretty_version, reference) + } else if reference.len() == 40 { + // dist-ref fallback (no git/hg source) — Composer truncates here too + format!("{} {}", pretty_version, &reference[..7]) + } else { + format!("{} {}", pretty_version, reference) + } +} + +/// Per-call configuration shared across executor methods. Owned by the +/// caller (typically `install_from_lock`) so the executor sees a consistent +/// view across an entire install/update run. +#[derive(Debug, Clone)] +pub struct ExecuteContext { + pub vendor_dir: PathBuf, + /// Suppress download progress bars. + pub no_progress: bool, + /// Prefer cloning from VCS source over downloading dist archives. + pub prefer_source: bool, +} + +/// Side-effect surface for install/update/uninstall operations. +/// +/// Implementations are stateful — `&mut self` lets a recorder accumulate +/// trace lines and lets the filesystem implementation hold long-lived +/// handles (caches, progress bars). All methods return `anyhow::Result` so +/// callers can short-circuit on the first failure, mirroring Composer's +/// fail-fast `InstallationManager::execute`. +#[async_trait::async_trait] +pub trait InstallerExecutor: Send + Sync { + /// Perform side effects for one install or update operation. + async fn install_package( + &mut self, + op: PackageOperation<'_>, + ctx: &ExecuteContext, + ) -> anyhow::Result<()>; + + /// Perform side effects for one uninstall. + /// + /// `version` is the previously-installed version (from installed.json), + /// passed so the trace recorder can format Composer's + /// `Uninstalling pkg/name (version)` line. The filesystem implementation + /// ignores it — `name` alone is enough to locate the vendor directory. + fn uninstall_package( + &mut self, + name: &str, + version: &str, + ctx: &ExecuteContext, + ) -> anyhow::Result<()>; + + /// Hook called once after every uninstall has run. Default no-op. + /// Composer cleans up empty namespace directories here; the recorder + /// has no work to do. + fn cleanup_after_uninstalls(&mut self, _ctx: &ExecuteContext) -> anyhow::Result<()> { + Ok(()) + } +} diff --git a/crates/mozart-core/src/repository/installer_executor/mod.rs b/crates/mozart-core/src/repository/installer_executor/mod.rs deleted file mode 100644 index f67c612..0000000 --- a/crates/mozart-core/src/repository/installer_executor/mod.rs +++ /dev/null @@ -1,348 +0,0 @@ -//! Installation execution abstraction. -//! -//! Mirrors `Composer\Installer\InstallationManager`: the per-operation -//! side-effect surface (download, extract, remove from vendor/) lives behind -//! a trait so test code can substitute a recording-only implementation -//! (Composer's `InstallationManagerMock`) without going anywhere near the -//! filesystem or the network. -//! -//! The orchestration loop (computing operations from lock vs installed, -//! emitting console messages, writing `installed.json`, generating the -//! autoloader) stays in the caller. The executor is purely the verb — -//! "install this package" / "uninstall this package" — so test traces match -//! Composer's `(string) $operation` byte-for-byte without the executor -//! having to also reproduce console formatting. - -use std::path::PathBuf; - -use super::installed::InstalledPackageEntry; -use super::lockfile::{LockAlias, LockedPackage}; - -pub mod filesystem; -pub mod trace_recorder; -pub mod transaction; - -pub use filesystem::FilesystemExecutor; -pub use trace_recorder::TraceRecorderExecutor; -pub use transaction::{ - Action, StaleInstalledAlias, compute_operations, compute_stale_installed_aliases, - locked_to_installed_entry, previously_installed_alias_versions, -}; - -/// One install or update operation handed to [`InstallerExecutor::install_package`]. -#[derive(Debug, Clone, Copy)] -pub enum PackageOperation<'a> { - /// First-time install. The whole package directory is created from - /// `package.dist`/`package.source`. - Install { package: &'a LockedPackage }, - /// Replace an existing install with a new version. `from_version` is the - /// pretty version that was installed before (no reference suffix — - /// drives the upgrade-vs-downgrade direction). `from_full_pretty` / - /// `to_full_pretty` are the formatted display strings used verbatim in - /// the trace output; the caller renders them via - /// [`format_update_pretty_versions`] so the SOURCE_REF / DIST_REF mode - /// switch from Composer's `UpdateOperation::format` lands on both sides. - Update { - from_version: &'a str, - from_full_pretty: &'a str, - to_full_pretty: &'a str, - package: &'a LockedPackage, - }, - /// Mark an alias of a real package as installed. No filesystem effects — - /// only the trace recorder needs this. Mirrors Composer's - /// `MarkAliasInstalledOperation`. - MarkAliasInstalled { - /// The alias entry from `composer.lock`'s `aliases[]` block. Carries - /// pretty + normalized alias version and the target's pretty version. - alias: &'a LockAlias, - /// The target package the alias points at — used to source the - /// reference suffix for the trace line. - target: &'a LockedPackage, - }, - /// Mark a previously-installed alias as uninstalled. No filesystem - /// effects — only the trace recorder cares. Mirrors Composer's - /// `MarkAliasUninstalledOperation`. Composer derives the AliasPackage - /// from the previous installed.json entries (via `extra.branch-alias`), - /// then emits this when the alias is no longer in the result. Caller - /// pre-renders the display strings so this variant doesn't need to know - /// how to spelunk the entry. - MarkAliasUninstalled { - /// Package name (e.g. `a/a`) used as both the alias's name and the - /// target's name on the trace line. - name: &'a str, - /// Alias's full-pretty form (alias pretty version plus reference - /// suffix), e.g. `1.0.x-dev master`. - alias_full: &'a str, - /// Target's full-pretty form, e.g. `dev-master master`. - target_full: &'a str, - }, -} - -impl<'a> PackageOperation<'a> { - pub fn package(&self) -> Option<&'a LockedPackage> { - match self { - PackageOperation::Install { package } | PackageOperation::Update { package, .. } => { - Some(package) - } - PackageOperation::MarkAliasInstalled { .. } - | PackageOperation::MarkAliasUninstalled { .. } => None, - } - } -} - -/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for a `LockedPackage`. -/// -/// For dev-stability versions backed by a git/hg source, append the reference -/// (truncated to 7 chars when it looks like a 40-char sha1). Otherwise return -/// the pretty version unchanged. -pub fn format_full_pretty_version(pkg: &LockedPackage) -> String { - format_full_pretty_with_pretty(&pkg.version, pkg) -} - -/// Same as [`format_full_pretty_version`] but lets the caller supply an -/// alternate pretty version (used by `MarkAliasInstalled` so the alias's -/// `3.2.x-dev` text is rendered with the *target's* reference). -pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage) -> String { - let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref()); - let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); - let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str()); - format_full_pretty_with_refs( - pretty_version, - &pkg.version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Render an alias's full pretty version: the alias's own pretty form for -/// the visible text, the alias's *normalized* version for the dev-stability -/// gate, and the target package's source/dist references for the suffix. -/// Mirrors `AliasPackage::getFullPrettyVersion`, where the alias decides on -/// its own whether to append a reference based on its own stability — so a -/// stable alias like `1.0.0` skips the suffix even when the target is a dev -/// branch. -pub fn format_full_pretty_alias( - alias_pretty: &str, - alias_version: &str, - target: &LockedPackage, -) -> String { - let source_ref = target.source.as_ref().and_then(|s| s.reference.as_deref()); - let dist_ref = target.dist.as_ref().and_then(|d| d.reference.as_deref()); - let source_type = target.source.as_ref().map(|s| s.source_type.as_str()); - format_full_pretty_with_refs( - alias_pretty, - alias_version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Same as [`format_full_pretty_version_for_installed`] but lets the caller -/// supply an alternate pretty version. Used when emitting -/// `MarkAliasUninstalled`: the alias's `1.0.x-dev` text needs to be rendered -/// with the *target installed entry's* reference suffix. -pub fn format_full_pretty_with_pretty_for_installed( - pretty_version: &str, - entry: &InstalledPackageEntry, -) -> String { - let source_ref = entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let dist_ref = entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let source_type = entry - .source - .as_ref() - .and_then(|v| v.get("type")) - .and_then(|v| v.as_str()); - format_full_pretty_with_refs( - pretty_version, - &entry.version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for an -/// `InstalledPackageEntry`. Same display rules as -/// [`format_full_pretty_version`] but pulls source/dist info out of the -/// installed.json `source`/`dist` JSON values. -pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -> String { - format_full_pretty_with_pretty_for_installed(&entry.version, entry) -} - -/// Render the from/to display strings for an update trace line, mirroring -/// Composer's `UpdateOperation::format`. Defaults to `DISPLAY_SOURCE_REF_IF_DEV`, -/// then if both sides render identically: -/// -/// - source references differ → re-render in `DISPLAY_SOURCE_REF` mode, -/// - else dist references differ → re-render in `DISPLAY_DIST_REF` mode. -/// -/// Without the switch, two same-version-different-reference packages would -/// produce a useless `pkg (X => X)` trace line. -pub fn format_update_pretty_versions( - from_entry: &InstalledPackageEntry, - to_pkg: &LockedPackage, -) -> (String, String) { - let from_default = format_full_pretty_version_for_installed(from_entry); - let to_default = format_full_pretty_version(to_pkg); - if from_default != to_default { - return (from_default, to_default); - } - - let from_source_ref = from_entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let from_source_type = from_entry - .source - .as_ref() - .and_then(|v| v.get("type")) - .and_then(|v| v.as_str()); - let to_source_ref = to_pkg.source.as_ref().and_then(|s| s.reference.as_deref()); - let to_source_type = to_pkg.source.as_ref().map(|s| s.source_type.as_str()); - - if from_source_ref != to_source_ref { - return ( - format_with_explicit_reference(&from_entry.version, from_source_ref, from_source_type), - format_with_explicit_reference(&to_pkg.version, to_source_ref, to_source_type), - ); - } - - let from_dist_ref = from_entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let to_dist_ref = to_pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); - - if from_dist_ref != to_dist_ref { - return ( - format_with_explicit_reference(&from_entry.version, from_dist_ref, from_source_type), - format_with_explicit_reference(&to_pkg.version, to_dist_ref, to_source_type), - ); - } - - (from_default, to_default) -} - -/// Render `pretty_version` with an explicitly chosen reference, mirroring -/// Composer's `BasePackage::getFullPrettyVersion` with `DISPLAY_SOURCE_REF` -/// or `DISPLAY_DIST_REF`: skip the dev-stability gate, just truncate sha1 -/// references and concatenate. A `None` reference falls back to the bare -/// pretty version. -fn format_with_explicit_reference( - pretty_version: &str, - reference: Option<&str>, - source_type: Option<&str>, -) -> String { - let Some(reference) = reference else { - return pretty_version.to_string(); - }; - if matches!(source_type, Some("svn")) { - return format!("{} {}", pretty_version, reference); - } - if reference.len() == 40 { - return format!("{} {}", pretty_version, &reference[..7]); - } - format!("{} {}", pretty_version, reference) -} - -/// Core of `BasePackage::getFullPrettyVersion()` factored over raw -/// fields so both [`LockedPackage`] and [`InstalledPackageEntry`] can share -/// the rendering logic. `version` drives the dev-stability check; the result -/// is `pretty_version` plus a reference suffix when the package is a dev -/// branch backed by git/hg (with sha1 references truncated to 7 chars). -fn format_full_pretty_with_refs( - pretty_version: &str, - version: &str, - source_ref: Option<&str>, - dist_ref: Option<&str>, - source_type: Option<&str>, -) -> String { - let is_dev = mozart_semver::Version::parse(version) - .map(|v| matches!(v.pre_release.as_deref(), Some("dev")) || v.is_dev_branch) - .unwrap_or(false); - if !is_dev { - return pretty_version.to_string(); - } - // Composer falls back to dist reference only when no source type is set - // (or the package isn't git/hg — in which case the dev display is skipped - // entirely above). - let reference = source_ref.or(match source_type { - Some("git") | Some("hg") => None, - _ => dist_ref, - }); - let Some(reference) = reference else { - return pretty_version.to_string(); - }; - if matches!(source_type, Some("git") | Some("hg")) && reference.len() == 40 { - format!("{} {}", pretty_version, &reference[..7]) - } else if matches!(source_type, Some("svn")) { - // svn references are revision numbers, never truncated - format!("{} {}", pretty_version, reference) - } else if reference.len() == 40 { - // dist-ref fallback (no git/hg source) — Composer truncates here too - format!("{} {}", pretty_version, &reference[..7]) - } else { - format!("{} {}", pretty_version, reference) - } -} - -/// Per-call configuration shared across executor methods. Owned by the -/// caller (typically `install_from_lock`) so the executor sees a consistent -/// view across an entire install/update run. -#[derive(Debug, Clone)] -pub struct ExecuteContext { - pub vendor_dir: PathBuf, - /// Suppress download progress bars. - pub no_progress: bool, - /// Prefer cloning from VCS source over downloading dist archives. - pub prefer_source: bool, -} - -/// Side-effect surface for install/update/uninstall operations. -/// -/// Implementations are stateful — `&mut self` lets a recorder accumulate -/// trace lines and lets the filesystem implementation hold long-lived -/// handles (caches, progress bars). All methods return `anyhow::Result` so -/// callers can short-circuit on the first failure, mirroring Composer's -/// fail-fast `InstallationManager::execute`. -#[async_trait::async_trait] -pub trait InstallerExecutor: Send + Sync { - /// Perform side effects for one install or update operation. - async fn install_package( - &mut self, - op: PackageOperation<'_>, - ctx: &ExecuteContext, - ) -> anyhow::Result<()>; - - /// Perform side effects for one uninstall. - /// - /// `version` is the previously-installed version (from installed.json), - /// passed so the trace recorder can format Composer's - /// `Uninstalling pkg/name (version)` line. The filesystem implementation - /// ignores it — `name` alone is enough to locate the vendor directory. - fn uninstall_package( - &mut self, - name: &str, - version: &str, - ctx: &ExecuteContext, - ) -> anyhow::Result<()>; - - /// Hook called once after every uninstall has run. Default no-op. - /// Composer cleans up empty namespace directories here; the recorder - /// has no work to do. - fn cleanup_after_uninstalls(&mut self, _ctx: &ExecuteContext) -> anyhow::Result<()> { - Ok(()) - } -} diff --git a/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs b/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs index b60a869..5dd39b0 100644 --- a/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs +++ b/crates/mozart-core/src/repository/installer_executor/trace_recorder.rs @@ -14,12 +14,11 @@ //! - Update (downgrade direction): `Downgrading ( => )` //! - Uninstall: `Removing ()` -use mozart_semver::Version; - use super::{ ExecuteContext, InstallerExecutor, PackageOperation, format_full_pretty_alias, format_full_pretty_version, }; +use mozart_semver::Version; /// Recording-only executor. Construct with [`TraceRecorderExecutor::new`], /// then read [`TraceRecorderExecutor::trace`] after the run completes. diff --git a/crates/mozart-core/src/repository/path_repository.rs b/crates/mozart-core/src/repository/path_repository.rs index 0cff012..2353809 100644 --- a/crates/mozart-core/src/repository/path_repository.rs +++ b/crates/mozart-core/src/repository/path_repository.rs @@ -19,11 +19,10 @@ //! consumers comparing references against Composer-produced lockfiles see //! byte-identical values. -use std::path::{Path, PathBuf}; - use crate::package::RawRepository; -use mozart_php_serialize::{Value as PhpValue, serialize as php_serialize}; +use mozart_php_serialize::{Value, serialize}; use sha1::{Digest as _, Sha1}; +use std::path::{Path, PathBuf}; /// Translate path repos in `repositories` into synthetic `type: package` /// entries. Non-path entries are returned unchanged in original order. @@ -123,11 +122,11 @@ fn resolve_path(url: &str, base_dir: &Path) -> PathBuf { /// flag is the only option Composer's auto-detection populates when the user /// supplied no `options` block. fn compute_path_reference(json_bytes: &[u8], is_relative: bool) -> String { - let options = PhpValue::Array(vec![( - PhpValue::String("relative".to_string()), - PhpValue::Bool(is_relative), + let options = Value::Array(vec![( + Value::String("relative".to_string()), + Value::Bool(is_relative), )]); - let serialized = php_serialize(&options); + let serialized = serialize(&options); let mut hasher = Sha1::new(); hasher.update(json_bytes); hasher.update(serialized.as_bytes()); diff --git a/crates/mozart-core/src/repository/repository.rs b/crates/mozart-core/src/repository/repository.rs new file mode 100644 index 0000000..ece0c5f --- /dev/null +++ b/crates/mozart-core/src/repository/repository.rs @@ -0,0 +1,318 @@ +//! Repository abstraction over package metadata sources. +//! +//! Mirrors Composer's `Composer\Repository\RepositoryInterface::loadPackages` +//! and `Composer\Repository\RepositoryManager`. The resolver and lockfile +//! generator query a [`RepositorySet`] instead of calling Packagist directly, +//! so test code can substitute a set without `PackagistRepository` (mirroring +//! Composer's `FactoryMock` injecting `repositories: ['packagist' => false]`). +//! +//! Concrete implementations live in sibling modules: [`packagist_repo`] for +//! the live Packagist HTTP repo, [`inline_package_repo`] for `type: package` +//! entries embedded in `composer.json`, and [`vcs_repo`] for VCS repositories. + +use super::advisory::{MatchedAdvisory, PackageInfo}; +use super::packagist::{PackagistVersion, SearchResult}; +use std::collections::BTreeMap; + +pub mod inline_package_repo; +pub mod packagist_repo; +pub mod vcs_repo; + +/// Search modes for [`Repository::search`]. +/// +/// Mirrors Composer's `RepositoryInterface::SEARCH_FULLTEXT|SEARCH_NAME|SEARCH_VENDOR` +/// constants (`composer/src/Composer/Repository/RepositoryInterface.php`). +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum SearchMode { + /// Full-text search over name, description, and keywords (Packagist's + /// `search.json` API). + Fulltext, + /// Match the regex against package names. Tokens are split on whitespace + /// and joined as `(?:t1|t2|...)`; callers must pre-quote regex metachars. + Name, + /// Match the regex against vendor names. Result rows have only `name` + /// populated (the vendor part). + Vendor, +} + +/// One name-keyed lookup against a repository. +/// +/// Matches the `$packageNameMap` argument of Composer's `loadPackages`. The +/// constraint is informational — repositories may use it to skip versions +/// that obviously can't match (an optimization), but the resolver still +/// re-checks every returned version when generating rules. +#[derive(Debug, Clone)] +pub struct PackageQuery<'a> { + pub name: &'a str, + /// Raw constraint string from `composer.json`, e.g. `"^1.2"`. `None` + /// when the caller wants every version (transitive exploration). + pub constraint: Option<&'a str>, +} + +/// Result of a single [`Repository::load_packages`] call. +/// +/// Mirrors Composer's `['packages' => ..., 'namesFound' => ...]` tuple. +/// `names_found` lets [`RepositorySet`] short-circuit lower-priority repos +/// once an upstream repo has authoritatively answered for a name (Composer's +/// "first repo wins" semantics). +#[derive(Debug, Default)] +pub struct LoadResult { + pub packages: Vec, + pub names_found: Vec, +} + +/// A `PackagistVersion` paired with the canonical package name it answers +/// for. Inline `type: package` repos can return packages whose own `name` +/// field differs from the queried name when they declare `replace`/`provide`, +/// so callers need both. +#[derive(Debug, Clone)] +pub struct NamedPackagistVersion { + pub name: String, + pub version: PackagistVersion, +} + +/// A source of package metadata. Mirrors Composer's `RepositoryInterface`. +/// +/// Implementations should return an empty [`LoadResult`] (not an error) when +/// they simply don't know a queried name — [`RepositorySet`] uses that to +/// fall through to the next repo. Reserve `Err` for genuine I/O failures +/// the caller cannot route around. +#[async_trait::async_trait] +pub trait Repository: Send + Sync { + /// Identifier for diagnostics (`"packagist.org"`, `"package"`, `"vcs:"`). + fn id(&self) -> &str; + + /// Look up every version of every queried name this repo knows about. + async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result; + + /// Search this repository. + /// + /// The default returns an empty result so repositories that don't + /// participate in search (e.g. inline / VCS repos that only resolve + /// known names) can opt out. Mirrors Composer's + /// `RepositoryInterface::search` whose default behavior on + /// `ArrayRepository` walks the in-memory list. + async fn search( + &self, + _query: &str, + _mode: SearchMode, + _package_type: Option<&str>, + ) -> anyhow::Result> { + Ok(Vec::new()) + } +} + +/// Ordered list of repositories. Mirrors `Composer\Repository\RepositoryManager`. +/// +/// `load_packages` queries each repo in order. Once a repo authoritatively +/// answers for a name (i.e. lists it in `names_found`), later repos are not +/// asked about that name — matching Composer's first-repo-wins priority. +pub struct RepositorySet { + repos: Vec>, +} + +impl RepositorySet { + pub fn new(repos: Vec>) -> Self { + Self { repos } + } + + /// Production default: a single [`packagist_repo::PackagistRepository`] + /// backed by the given on-disk cache. Mirrors what Composer does when + /// no `'packagist' => false` entry appears in the merged config. + pub fn with_packagist(repo_cache: super::cache::Cache) -> Self { + Self::new(vec![Box::new(packagist_repo::PackagistRepository::new( + repo_cache, + ))]) + } + + /// An empty set. Mirrors Composer's `'packagist' => false` test config: + /// resolution proceeds entirely from packages already in the pool + /// (eager VCS scan, inline `type: package` repos, the locked repository). + pub fn empty() -> Self { + Self::new(Vec::new()) + } + + pub fn is_empty(&self) -> bool { + self.repos.is_empty() + } + + pub fn len(&self) -> usize { + self.repos.len() + } + + /// Iterate over repositories in priority order. + pub fn repos(&self) -> impl Iterator { + self.repos.iter().map(|b| b.as_ref()) + } + + /// Query every repo, accumulating packages and tracking which names have + /// been authoritatively answered. Names already covered by an earlier + /// repo are dropped from the query passed to later repos. + pub async fn load_packages( + &self, + queries: &[PackageQuery<'_>], + ) -> anyhow::Result> { + use indexmap::IndexSet; + + let mut packages: Vec = Vec::new(); + let mut answered: IndexSet = IndexSet::new(); + + for repo in &self.repos { + let pending: Vec> = queries + .iter() + .filter(|q| !answered.contains(q.name)) + .cloned() + .collect(); + if pending.is_empty() { + break; + } + let result = repo.load_packages(&pending).await?; + for name in result.names_found { + answered.insert(name); + } + packages.extend(result.packages); + } + + Ok(packages) + } + + /// Fan-out search across every repository, concatenating results in + /// priority order. Mirrors Composer's + /// `CompositeRepository::search` which `array_merge`s per-repo results + /// without de-duplication. + pub async fn search( + &self, + query: &str, + mode: SearchMode, + package_type: Option<&str>, + ) -> anyhow::Result> { + let mut all = Vec::new(); + for repo in &self.repos { + let mut hits = repo.search(query, mode, package_type).await?; + all.append(&mut hits); + } + Ok(all) + } + + /// Fetch security advisories matching the installed packages, with version filtering. + /// + /// Mirrors `Composer\Repository\RepositorySet::getMatchingSecurityAdvisories()`. + /// Returns the matched advisories (already filtered by installed version) and a list + /// of unreachable repository URLs. When `ignore_unreachable` is false and a repository + /// is unreachable, the error is propagated instead. + pub async fn get_matching_security_advisories( + &self, + packages: &[PackageInfo], + _allow_partial: bool, + ignore_unreachable: bool, + ) -> anyhow::Result<(BTreeMap>, Vec)> { + let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect(); + + let (raw_advisories, unreachable_repos) = + match super::packagist::fetch_security_advisories(&names).await { + Ok(a) => (a, vec![]), + Err(e) if ignore_unreachable => { + tracing::warn!("Packagist advisory fetch failed (ignored): {e}"); + let unreachable = vec!["https://packagist.org".to_string()]; + (BTreeMap::new(), unreachable) + } + Err(e) => return Err(e), + }; + + let matched = version_filter_advisories(&raw_advisories, packages); + + Ok((matched, unreachable_repos)) + } +} + +/// Normalize single-pipe OR separators (`|`) in a version constraint string to +/// double-pipe (`||`) so the constraint parser can handle both forms. +/// +/// The Packagist security advisories API may return constraints with single `|` +/// as the OR separator (e.g. `>=1.0,<1.5|>=2.0,<2.3`), but Mozart's +/// `VersionConstraint::parse` expects `||`. +/// +/// TODO: fix `mozart_semver::VersionConstraint::parse` to accept single `|` and remove this. +fn normalize_or_separator(constraint: &str) -> String { + let bytes = constraint.as_bytes(); + let mut result = String::with_capacity(constraint.len() + 4); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'|' { + if i + 1 < bytes.len() && bytes[i + 1] == b'|' { + result.push_str("||"); + i += 2; + } else { + result.push_str("||"); + i += 1; + } + } else { + result.push(bytes[i] as char); + i += 1; + } + } + result +} + +/// Filter raw advisories by installed package versions. +/// +/// Mirrors the version-matching step inside Composer's repository advisory fetch. +fn version_filter_advisories( + all_advisories: &BTreeMap>, + packages: &[PackageInfo], +) -> BTreeMap> { + let mut result: BTreeMap> = BTreeMap::new(); + + for pkg in packages { + let Some(advisories) = all_advisories.get(&pkg.name) else { + continue; + }; + + let version_str = pkg + .version_normalized + .as_deref() + .unwrap_or(pkg.version.as_str()); + + let installed_ver = match mozart_semver::Version::parse(version_str) { + Ok(v) => v, + Err(_) => { + tracing::warn!( + "Could not parse version {:?} for package {:?}, skipping advisory matching", + version_str, + pkg.name + ); + continue; + } + }; + + let mut matched: Vec = Vec::new(); + + for advisory in advisories { + let normalized = normalize_or_separator(&advisory.affected_versions); + let constraint = match mozart_semver::VersionConstraint::parse(&normalized) { + Ok(c) => c, + Err(_) => { + tracing::warn!( + "Could not parse affected versions {:?} for advisory {:?}, skipping", + advisory.affected_versions, + advisory.advisory_id + ); + continue; + } + }; + + if constraint.matches(&installed_ver) { + matched.push(MatchedAdvisory { + advisory: advisory.clone(), + installed_version: pkg.version.clone(), + }); + } + } + + if !matched.is_empty() { + result.insert(pkg.name.clone(), matched); + } + } + + result +} diff --git a/crates/mozart-core/src/repository/repository/mod.rs b/crates/mozart-core/src/repository/repository/mod.rs deleted file mode 100644 index 4afff54..0000000 --- a/crates/mozart-core/src/repository/repository/mod.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Repository abstraction over package metadata sources. -//! -//! Mirrors Composer's `Composer\Repository\RepositoryInterface::loadPackages` -//! and `Composer\Repository\RepositoryManager`. The resolver and lockfile -//! generator query a [`RepositorySet`] instead of calling Packagist directly, -//! so test code can substitute a set without `PackagistRepository` (mirroring -//! Composer's `FactoryMock` injecting `repositories: ['packagist' => false]`). -//! -//! Concrete implementations live in sibling modules: [`packagist_repo`] for -//! the live Packagist HTTP repo, [`inline_package_repo`] for `type: package` -//! entries embedded in `composer.json`, and [`vcs_repo`] for VCS repositories. - -use std::collections::BTreeMap; - -use super::advisory::{MatchedAdvisory, PackageInfo}; -use super::packagist::{PackagistVersion, SearchResult}; - -pub mod inline_package_repo; -pub mod packagist_repo; -pub mod vcs_repo; - -/// Search modes for [`Repository::search`]. -/// -/// Mirrors Composer's `RepositoryInterface::SEARCH_FULLTEXT|SEARCH_NAME|SEARCH_VENDOR` -/// constants (`composer/src/Composer/Repository/RepositoryInterface.php`). -#[derive(Copy, Clone, Eq, PartialEq, Debug)] -pub enum SearchMode { - /// Full-text search over name, description, and keywords (Packagist's - /// `search.json` API). - Fulltext, - /// Match the regex against package names. Tokens are split on whitespace - /// and joined as `(?:t1|t2|...)`; callers must pre-quote regex metachars. - Name, - /// Match the regex against vendor names. Result rows have only `name` - /// populated (the vendor part). - Vendor, -} - -/// One name-keyed lookup against a repository. -/// -/// Matches the `$packageNameMap` argument of Composer's `loadPackages`. The -/// constraint is informational — repositories may use it to skip versions -/// that obviously can't match (an optimization), but the resolver still -/// re-checks every returned version when generating rules. -#[derive(Debug, Clone)] -pub struct PackageQuery<'a> { - pub name: &'a str, - /// Raw constraint string from `composer.json`, e.g. `"^1.2"`. `None` - /// when the caller wants every version (transitive exploration). - pub constraint: Option<&'a str>, -} - -/// Result of a single [`Repository::load_packages`] call. -/// -/// Mirrors Composer's `['packages' => ..., 'namesFound' => ...]` tuple. -/// `names_found` lets [`RepositorySet`] short-circuit lower-priority repos -/// once an upstream repo has authoritatively answered for a name (Composer's -/// "first repo wins" semantics). -#[derive(Debug, Default)] -pub struct LoadResult { - pub packages: Vec, - pub names_found: Vec, -} - -/// A `PackagistVersion` paired with the canonical package name it answers -/// for. Inline `type: package` repos can return packages whose own `name` -/// field differs from the queried name when they declare `replace`/`provide`, -/// so callers need both. -#[derive(Debug, Clone)] -pub struct NamedPackagistVersion { - pub name: String, - pub version: PackagistVersion, -} - -/// A source of package metadata. Mirrors Composer's `RepositoryInterface`. -/// -/// Implementations should return an empty [`LoadResult`] (not an error) when -/// they simply don't know a queried name — [`RepositorySet`] uses that to -/// fall through to the next repo. Reserve `Err` for genuine I/O failures -/// the caller cannot route around. -#[async_trait::async_trait] -pub trait Repository: Send + Sync { - /// Identifier for diagnostics (`"packagist.org"`, `"package"`, `"vcs:"`). - fn id(&self) -> &str; - - /// Look up every version of every queried name this repo knows about. - async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result; - - /// Search this repository. - /// - /// The default returns an empty result so repositories that don't - /// participate in search (e.g. inline / VCS repos that only resolve - /// known names) can opt out. Mirrors Composer's - /// `RepositoryInterface::search` whose default behavior on - /// `ArrayRepository` walks the in-memory list. - async fn search( - &self, - _query: &str, - _mode: SearchMode, - _package_type: Option<&str>, - ) -> anyhow::Result> { - Ok(Vec::new()) - } -} - -/// Ordered list of repositories. Mirrors `Composer\Repository\RepositoryManager`. -/// -/// `load_packages` queries each repo in order. Once a repo authoritatively -/// answers for a name (i.e. lists it in `names_found`), later repos are not -/// asked about that name — matching Composer's first-repo-wins priority. -pub struct RepositorySet { - repos: Vec>, -} - -impl RepositorySet { - pub fn new(repos: Vec>) -> Self { - Self { repos } - } - - /// Production default: a single [`packagist_repo::PackagistRepository`] - /// backed by the given on-disk cache. Mirrors what Composer does when - /// no `'packagist' => false` entry appears in the merged config. - pub fn with_packagist(repo_cache: super::cache::Cache) -> Self { - Self::new(vec![Box::new(packagist_repo::PackagistRepository::new( - repo_cache, - ))]) - } - - /// An empty set. Mirrors Composer's `'packagist' => false` test config: - /// resolution proceeds entirely from packages already in the pool - /// (eager VCS scan, inline `type: package` repos, the locked repository). - pub fn empty() -> Self { - Self::new(Vec::new()) - } - - pub fn is_empty(&self) -> bool { - self.repos.is_empty() - } - - pub fn len(&self) -> usize { - self.repos.len() - } - - /// Iterate over repositories in priority order. - pub fn repos(&self) -> impl Iterator { - self.repos.iter().map(|b| b.as_ref()) - } - - /// Query every repo, accumulating packages and tracking which names have - /// been authoritatively answered. Names already covered by an earlier - /// repo are dropped from the query passed to later repos. - pub async fn load_packages( - &self, - queries: &[PackageQuery<'_>], - ) -> anyhow::Result> { - use indexmap::IndexSet; - - let mut packages: Vec = Vec::new(); - let mut answered: IndexSet = IndexSet::new(); - - for repo in &self.repos { - let pending: Vec> = queries - .iter() - .filter(|q| !answered.contains(q.name)) - .cloned() - .collect(); - if pending.is_empty() { - break; - } - let result = repo.load_packages(&pending).await?; - for name in result.names_found { - answered.insert(name); - } - packages.extend(result.packages); - } - - Ok(packages) - } - - /// Fan-out search across every repository, concatenating results in - /// priority order. Mirrors Composer's - /// `CompositeRepository::search` which `array_merge`s per-repo results - /// without de-duplication. - pub async fn search( - &self, - query: &str, - mode: SearchMode, - package_type: Option<&str>, - ) -> anyhow::Result> { - let mut all = Vec::new(); - for repo in &self.repos { - let mut hits = repo.search(query, mode, package_type).await?; - all.append(&mut hits); - } - Ok(all) - } - - /// Fetch security advisories matching the installed packages, with version filtering. - /// - /// Mirrors `Composer\Repository\RepositorySet::getMatchingSecurityAdvisories()`. - /// Returns the matched advisories (already filtered by installed version) and a list - /// of unreachable repository URLs. When `ignore_unreachable` is false and a repository - /// is unreachable, the error is propagated instead. - pub async fn get_matching_security_advisories( - &self, - packages: &[PackageInfo], - _allow_partial: bool, - ignore_unreachable: bool, - ) -> anyhow::Result<(BTreeMap>, Vec)> { - let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect(); - - let (raw_advisories, unreachable_repos) = - match super::packagist::fetch_security_advisories(&names).await { - Ok(a) => (a, vec![]), - Err(e) if ignore_unreachable => { - tracing::warn!("Packagist advisory fetch failed (ignored): {e}"); - let unreachable = vec!["https://packagist.org".to_string()]; - (BTreeMap::new(), unreachable) - } - Err(e) => return Err(e), - }; - - let matched = version_filter_advisories(&raw_advisories, packages); - - Ok((matched, unreachable_repos)) - } -} - -/// Normalize single-pipe OR separators (`|`) in a version constraint string to -/// double-pipe (`||`) so the constraint parser can handle both forms. -/// -/// The Packagist security advisories API may return constraints with single `|` -/// as the OR separator (e.g. `>=1.0,<1.5|>=2.0,<2.3`), but Mozart's -/// `VersionConstraint::parse` expects `||`. -/// -/// TODO: fix `mozart_semver::VersionConstraint::parse` to accept single `|` and remove this. -fn normalize_or_separator(constraint: &str) -> String { - let bytes = constraint.as_bytes(); - let mut result = String::with_capacity(constraint.len() + 4); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'|' { - if i + 1 < bytes.len() && bytes[i + 1] == b'|' { - result.push_str("||"); - i += 2; - } else { - result.push_str("||"); - i += 1; - } - } else { - result.push(bytes[i] as char); - i += 1; - } - } - result -} - -/// Filter raw advisories by installed package versions. -/// -/// Mirrors the version-matching step inside Composer's repository advisory fetch. -fn version_filter_advisories( - all_advisories: &BTreeMap>, - packages: &[PackageInfo], -) -> BTreeMap> { - let mut result: BTreeMap> = BTreeMap::new(); - - for pkg in packages { - let Some(advisories) = all_advisories.get(&pkg.name) else { - continue; - }; - - let version_str = pkg - .version_normalized - .as_deref() - .unwrap_or(pkg.version.as_str()); - - let installed_ver = match mozart_semver::Version::parse(version_str) { - Ok(v) => v, - Err(_) => { - tracing::warn!( - "Could not parse version {:?} for package {:?}, skipping advisory matching", - version_str, - pkg.name - ); - continue; - } - }; - - let mut matched: Vec = Vec::new(); - - for advisory in advisories { - let normalized = normalize_or_separator(&advisory.affected_versions); - let constraint = match mozart_semver::VersionConstraint::parse(&normalized) { - Ok(c) => c, - Err(_) => { - tracing::warn!( - "Could not parse affected versions {:?} for advisory {:?}, skipping", - advisory.affected_versions, - advisory.advisory_id - ); - continue; - } - }; - - if constraint.matches(&installed_ver) { - matched.push(MatchedAdvisory { - advisory: advisory.clone(), - installed_version: pkg.version.clone(), - }); - } - } - - if !matched.is_empty() { - result.insert(pkg.name.clone(), matched); - } - } - - result -} diff --git a/crates/mozart-core/src/vcs/process.rs b/crates/mozart-core/src/vcs/process.rs index 8ccc11d..7538d55 100644 --- a/crates/mozart-core/src/vcs/process.rs +++ b/crates/mozart-core/src/vcs/process.rs @@ -1,10 +1,9 @@ +use anyhow::{Result, bail}; use indexmap::IndexMap; use std::path::Path; use std::process::Command; use std::time::{Duration, Instant}; -use anyhow::{Result, bail}; - /// Output from a process execution. #[derive(Debug, Clone)] pub struct ProcessOutput { diff --git a/crates/mozart-core/src/vcs/util.rs b/crates/mozart-core/src/vcs/util.rs new file mode 100644 index 0000000..b2c35fc --- /dev/null +++ b/crates/mozart-core/src/vcs/util.rs @@ -0,0 +1,3 @@ +pub mod git; +pub mod hg; +pub mod svn; diff --git a/crates/mozart-core/src/vcs/util/mod.rs b/crates/mozart-core/src/vcs/util/mod.rs deleted file mode 100644 index b2c35fc..0000000 --- a/crates/mozart-core/src/vcs/util/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod git; -pub mod hg; -pub mod svn; -- cgit v1.3.1