From 0a8e5935e6305819bb02d8c69e2f046ff397913a Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 22 Feb 2026 00:37:54 +0900 Subject: refactor(workspace): split monolithic crate into 6 workspace crates Extract modules from the single `mozart` crate into 5 focused library crates to improve compilation parallelism and architectural clarity: - mozart-constraint: version constraint parser (independent) - mozart-core: base types, console, validation, platform utilities - mozart-archiver: archive creation (tar, zip, bzip2) - mozart-registry: Packagist API, cache, resolver, downloader, lockfile - mozart-autoload: autoloader generation and PHP scanner Refactor Console::from_cli and build_cache_config to accept primitive args instead of &Cli to break circular dependencies. Introduce [workspace.dependencies] for centralized version management. Remove 9 unused direct dependencies from the CLI crate. Co-Authored-By: Claude Opus 4.6 --- crates/mozart-core/src/console.rs | 358 ++++++++++++++++ crates/mozart-core/src/exit_code.rs | 114 +++++ crates/mozart-core/src/lib.rs | 7 + crates/mozart-core/src/package.rs | 703 +++++++++++++++++++++++++++++++ crates/mozart-core/src/platform.rs | 351 +++++++++++++++ crates/mozart-core/src/suggest.rs | 220 ++++++++++ crates/mozart-core/src/validation.rs | 226 ++++++++++ crates/mozart-core/src/version_bumper.rs | 667 +++++++++++++++++++++++++++++ 8 files changed, 2646 insertions(+) create mode 100644 crates/mozart-core/src/console.rs create mode 100644 crates/mozart-core/src/exit_code.rs create mode 100644 crates/mozart-core/src/lib.rs create mode 100644 crates/mozart-core/src/package.rs create mode 100644 crates/mozart-core/src/platform.rs create mode 100644 crates/mozart-core/src/suggest.rs create mode 100644 crates/mozart-core/src/validation.rs create mode 100644 crates/mozart-core/src/version_bumper.rs (limited to 'crates/mozart-core/src') diff --git a/crates/mozart-core/src/console.rs b/crates/mozart-core/src/console.rs new file mode 100644 index 0000000..e37ff23 --- /dev/null +++ b/crates/mozart-core/src/console.rs @@ -0,0 +1,358 @@ +use colored::{ColoredString, Colorize}; +use dialoguer::{Confirm, Input}; +use std::io::IsTerminal; + +// --------------------------------------------------------------------------- +// Tag-style color helpers (module-level free functions, unchanged API) +// --------------------------------------------------------------------------- + +/// `` — green foreground +pub fn info(message: &str) -> ColoredString { + message.green() +} + +/// `` — yellow foreground +pub fn comment(message: &str) -> ColoredString { + message.yellow() +} + +/// `` — white on red +pub fn error(message: &str) -> ColoredString { + message.white().on_red() +} + +/// `` — black on cyan +pub fn question(message: &str) -> ColoredString { + message.black().on_cyan() +} + +/// `` — red foreground (Composer extension) +pub fn highlight(message: &str) -> ColoredString { + message.red() +} + +/// `` — black on yellow (Composer extension) +pub fn warning(message: &str) -> ColoredString { + message.black().on_yellow() +} + +// --------------------------------------------------------------------------- +// Verbosity +// --------------------------------------------------------------------------- + +/// Output verbosity level, ordered from least to most verbose. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Verbosity { + /// `-q` / `--quiet`: suppress all non-error output. + Quiet, + /// Default: normal informational messages. + Normal, + /// `-v`: additional detail (URLs, cache hits, skips). + Verbose, + /// `-vv`: HTTP details, file operations, resolver iterations. + VeryVerbose, + /// `-vvv`: full debug output (headers, raw payloads, timing). + Debug, +} + +impl Verbosity { + /// Construct a `Verbosity` from CLI flag counts. + /// + /// - `quiet == true` → `Quiet` (takes priority over `-v` flags) + /// - `verbose_count == 0` → `Normal` + /// - `verbose_count == 1` → `Verbose` + /// - `verbose_count == 2` → `VeryVerbose` + /// - `verbose_count >= 3` → `Debug` + pub fn from_flags(verbose_count: u8, quiet: bool) -> Self { + if quiet { + return Verbosity::Quiet; + } + match verbose_count { + 0 => Verbosity::Normal, + 1 => Verbosity::Verbose, + 2 => Verbosity::VeryVerbose, + _ => Verbosity::Debug, + } + } +} + +// --------------------------------------------------------------------------- +// Console +// --------------------------------------------------------------------------- + +/// Central IO hub for Mozart commands. +/// +/// Constructed once in `commands::execute()` and passed as `&Console` to every +/// command and library function that needs to produce output. +pub struct Console { + /// Whether the user can answer interactive prompts. + pub interactive: bool, + /// Current verbosity level. + pub verbosity: Verbosity, + /// Whether ANSI color codes should be emitted. + pub decorated: bool, +} + +impl Console { + /// Build a `Console` from primitive arguments. + /// + /// This is the primary constructor. Pass the relevant CLI flag values: + /// - `verbose`: the `-v` flag count (0, 1, 2, 3+) + /// - `quiet`: whether `--quiet` was passed + /// - `ansi`: whether `--ansi` was passed + /// - `no_ansi`: whether `--no-ansi` was passed + /// - `no_interaction`: whether `--no-interaction` / `-n` was passed + pub fn new(verbose: u8, quiet: bool, ansi: bool, no_ansi: bool, no_interaction: bool) -> Self { + let verbosity = Verbosity::from_flags(verbose, quiet); + let decorated = Self::resolve_decorated(ansi, no_ansi); + colored::control::set_override(decorated); + Self { + interactive: !no_interaction, + verbosity, + decorated, + } + } + + /// Determine whether ANSI color output should be enabled. + /// + /// - `no_ansi == true` → always disable + /// - `ansi == true` → always enable + /// - Otherwise → auto-detect: enabled only when stderr is a TTY + pub fn resolve_decorated(ansi: bool, no_ansi: bool) -> bool { + if no_ansi { + return false; + } + if ansi { + return true; + } + std::io::stderr().is_terminal() + } + + // ----------------------------------------------------------------------- + // Output methods + // ----------------------------------------------------------------------- + + /// Write `msg` to stderr if `self.verbosity >= required`. + pub fn write(&self, msg: &str, required: Verbosity) { + if self.verbosity >= required { + eprintln!("{msg}"); + } + } + + /// Write `msg` to stdout if `self.verbosity >= required`. + pub fn write_stdout(&self, msg: &str, required: Verbosity) { + if self.verbosity >= required { + println!("{msg}"); + } + } + + /// Write an error to stderr. Always shown, even in quiet mode. + pub fn write_error(&self, msg: &str) { + eprintln!("{}", error(msg)); + } + + // Convenience verbosity-level shortcuts: + + /// Normal-level message (suppressed by `--quiet`). + pub fn info(&self, msg: &str) { + self.write(msg, Verbosity::Normal); + } + + /// Verbose-level message (shown with `-v` or higher). + pub fn verbose(&self, msg: &str) { + self.write(msg, Verbosity::Verbose); + } + + /// Very-verbose-level message (shown with `-vv` or higher). + pub fn very_verbose(&self, msg: &str) { + self.write(msg, Verbosity::VeryVerbose); + } + + /// Debug-level message (shown with `-vvv`). + pub fn debug(&self, msg: &str) { + self.write(msg, Verbosity::Debug); + } + + /// Error message — always shown. + pub fn error(&self, msg: &str) { + self.write_error(msg); + } + + // ----------------------------------------------------------------------- + // Query methods + // ----------------------------------------------------------------------- + + pub fn is_verbose(&self) -> bool { + self.verbosity >= Verbosity::Verbose + } + + pub fn is_very_verbose(&self) -> bool { + self.verbosity >= Verbosity::VeryVerbose + } + + pub fn is_debug(&self) -> bool { + self.verbosity >= Verbosity::Debug + } + + pub fn is_quiet(&self) -> bool { + self.verbosity == Verbosity::Quiet + } + + // ----------------------------------------------------------------------- + // Interactive prompt methods (unchanged from prior implementation) + // ----------------------------------------------------------------------- + + pub fn ask(&self, prompt: &str, default: &str) -> String { + if !self.interactive { + return default.to_string(); + } + + Input::new() + .with_prompt(prompt) + .default(default.to_string()) + .allow_empty(true) + .interact_text() + .unwrap_or_else(|_| default.to_string()) + } + + pub fn ask_validated( + &self, + prompt: &str, + default: &str, + validator: F, + ) -> Result + where + F: Fn(&str) -> Result<(), String>, + { + if !self.interactive { + validator(default)?; + return Ok(default.to_string()); + } + + loop { + let input: String = Input::new() + .with_prompt(prompt) + .default(default.to_string()) + .allow_empty(true) + .interact_text() + .unwrap_or_else(|_| default.to_string()); + + match validator(&input) { + Ok(()) => return Ok(input), + Err(e) => { + self.write_error(&e); + } + } + } + } + + pub fn confirm(&self, prompt: &str) -> bool { + if !self.interactive { + return true; + } + + Confirm::new() + .with_prompt(prompt) + .default(true) + .interact() + .unwrap_or(true) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // ── Verbosity::from_flags ─────────────────────────────────────────────── + + #[test] + fn test_verbosity_quiet_takes_priority() { + assert_eq!(Verbosity::from_flags(3, true), Verbosity::Quiet); + assert_eq!(Verbosity::from_flags(0, true), Verbosity::Quiet); + } + + #[test] + fn test_verbosity_normal() { + assert_eq!(Verbosity::from_flags(0, false), Verbosity::Normal); + } + + #[test] + fn test_verbosity_verbose() { + assert_eq!(Verbosity::from_flags(1, false), Verbosity::Verbose); + } + + #[test] + fn test_verbosity_very_verbose() { + assert_eq!(Verbosity::from_flags(2, false), Verbosity::VeryVerbose); + } + + #[test] + fn test_verbosity_debug() { + assert_eq!(Verbosity::from_flags(3, false), Verbosity::Debug); + assert_eq!(Verbosity::from_flags(10, false), Verbosity::Debug); + } + + // ── Verbosity ordering ────────────────────────────────────────────────── + + #[test] + fn test_verbosity_ordering() { + assert!(Verbosity::Quiet < Verbosity::Normal); + assert!(Verbosity::Normal < Verbosity::Verbose); + assert!(Verbosity::Verbose < Verbosity::VeryVerbose); + assert!(Verbosity::VeryVerbose < Verbosity::Debug); + } + + // ── Console::resolve_decorated ────────────────────────────────────────── + + #[test] + fn test_resolve_decorated_no_ansi_wins() { + assert!(!Console::resolve_decorated(true, true)); + assert!(!Console::resolve_decorated(false, true)); + } + + #[test] + fn test_resolve_decorated_ansi_forces_on() { + assert!(Console::resolve_decorated(true, false)); + } + + // ── Console query methods ─────────────────────────────────────────────── + + fn make_console(verbosity: Verbosity) -> Console { + Console { + interactive: false, + verbosity, + decorated: false, + } + } + + #[test] + fn test_is_quiet() { + assert!(make_console(Verbosity::Quiet).is_quiet()); + assert!(!make_console(Verbosity::Normal).is_quiet()); + } + + #[test] + fn test_is_verbose() { + assert!(!make_console(Verbosity::Normal).is_verbose()); + assert!(make_console(Verbosity::Verbose).is_verbose()); + assert!(make_console(Verbosity::VeryVerbose).is_verbose()); + assert!(make_console(Verbosity::Debug).is_verbose()); + } + + #[test] + fn test_is_very_verbose() { + assert!(!make_console(Verbosity::Verbose).is_very_verbose()); + assert!(make_console(Verbosity::VeryVerbose).is_very_verbose()); + assert!(make_console(Verbosity::Debug).is_very_verbose()); + } + + #[test] + fn test_is_debug() { + assert!(!make_console(Verbosity::VeryVerbose).is_debug()); + assert!(make_console(Verbosity::Debug).is_debug()); + } +} diff --git a/crates/mozart-core/src/exit_code.rs b/crates/mozart-core/src/exit_code.rs new file mode 100644 index 0000000..bc01cfa --- /dev/null +++ b/crates/mozart-core/src/exit_code.rs @@ -0,0 +1,114 @@ +/// Exit code: success. +pub const OK: i32 = 0; + +/// Exit code: general / unclassified error. +pub const GENERAL_ERROR: i32 = 1; + +/// Exit code: dependency resolution failed. +pub const DEPENDENCY_RESOLUTION_FAILED: i32 = 2; + +/// Exit code: partial update requested but no lock file exists. +pub const NO_LOCK_FILE_FOR_PARTIAL_UPDATE: i32 = 3; + +/// Exit code: lock file is invalid or corrupt. +pub const LOCK_FILE_INVALID: i32 = 4; + +/// Exit code: audit found a security advisory. +pub const AUDIT_FAILED: i32 = 5; + +/// Exit code: HTTP / network transport error. +pub const TRANSPORT_ERROR: i32 = 100; + +// --------------------------------------------------------------------------- +// MozartError — carries a specific exit code through anyhow's error chain +// --------------------------------------------------------------------------- + +/// An error type that carries a specific exit code for Mozart to use on exit. +/// +/// Use [`bail`] or [`bail_silent`] to construct one wrapped in `anyhow::Error`. +#[derive(Debug)] +pub struct MozartError { + pub message: String, + pub exit_code: i32, +} + +impl std::fmt::Display for MozartError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for MozartError {} + +/// Return an `anyhow::Error` that carries `exit_code` and prints `message`. +pub fn bail(exit_code: i32, message: impl Into) -> anyhow::Error { + MozartError { + message: message.into(), + exit_code, + } + .into() +} + +/// Return an `anyhow::Error` that carries `exit_code` but suppresses the +/// message (caller has already printed it). +pub fn bail_silent(exit_code: i32) -> anyhow::Error { + MozartError { + message: String::new(), + exit_code, + } + .into() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_constants_have_expected_values() { + assert_eq!(OK, 0); + assert_eq!(GENERAL_ERROR, 1); + assert_eq!(DEPENDENCY_RESOLUTION_FAILED, 2); + assert_eq!(NO_LOCK_FILE_FOR_PARTIAL_UPDATE, 3); + assert_eq!(LOCK_FILE_INVALID, 4); + assert_eq!(AUDIT_FAILED, 5); + assert_eq!(TRANSPORT_ERROR, 100); + } + + #[test] + fn test_mozart_error_display() { + let err = MozartError { + message: "something went wrong".to_string(), + exit_code: GENERAL_ERROR, + }; + assert_eq!(format!("{err}"), "something went wrong"); + } + + #[test] + fn test_bail_can_be_downcast() { + let err = bail(DEPENDENCY_RESOLUTION_FAILED, "cannot resolve"); + let me = err.downcast_ref::().expect("should downcast"); + assert_eq!(me.exit_code, DEPENDENCY_RESOLUTION_FAILED); + assert_eq!(me.message, "cannot resolve"); + } + + #[test] + fn test_bail_silent_has_empty_message() { + let err = bail_silent(GENERAL_ERROR); + let me = err.downcast_ref::().expect("should downcast"); + assert_eq!(me.exit_code, GENERAL_ERROR); + assert!(me.message.is_empty()); + } + + #[test] + fn test_mozart_error_is_std_error() { + let err: Box = Box::new(MozartError { + message: "test".to_string(), + exit_code: 1, + }); + assert_eq!(err.to_string(), "test"); + } +} diff --git a/crates/mozart-core/src/lib.rs b/crates/mozart-core/src/lib.rs new file mode 100644 index 0000000..b02e5a3 --- /dev/null +++ b/crates/mozart-core/src/lib.rs @@ -0,0 +1,7 @@ +pub mod console; +pub mod exit_code; +pub mod package; +pub mod platform; +pub mod suggest; +pub mod validation; +pub mod version_bumper; diff --git a/crates/mozart-core/src/package.rs b/crates/mozart-core/src/package.rs new file mode 100644 index 0000000..9904dc4 --- /dev/null +++ b/crates/mozart-core/src/package.rs @@ -0,0 +1,703 @@ +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +/// Package stability level. +/// Higher value = less stable. +/// Corresponds to `Composer\Package\BasePackage::STABILITY_*`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(u8)] +pub enum Stability { + #[default] + Stable = 0, + RC = 5, + Beta = 10, + Alpha = 15, + Dev = 20, +} + +impl Stability { + /// Parse a stability string (case-insensitive) into a `Stability` value. + /// + /// Recognizes: "stable", "RC", "beta", "alpha", "dev". + /// Defaults to `Stability::Stable` for unrecognized values. + pub fn parse(s: &str) -> Self { + match s.to_lowercase().as_str() { + "dev" => Stability::Dev, + "alpha" => Stability::Alpha, + "beta" => Stability::Beta, + "rc" => Stability::RC, + _ => Stability::Stable, + } + } +} + +/// A versioned relationship between two packages. +/// Corresponds to `Composer\Package\Link`. +#[derive(Debug, Clone)] +pub struct Link { + pub source: String, + pub target: String, + pub constraint: String, + pub pretty_constraint: Option, + pub description: String, +} + +/// Package author metadata. +#[derive(Debug, Clone)] +pub struct Author { + pub name: Option, + pub email: Option, + pub homepage: Option, + pub role: Option, +} + +/// Autoload rule sets (PSR-4, PSR-0, classmap, files). +#[derive(Debug, Clone, Default)] +pub struct AutoloadRules { + pub psr4: BTreeMap>, + pub psr0: BTreeMap>, + pub classmap: Vec, + pub files: Vec, +} + +/// Support channel information. +#[derive(Debug, Clone, Default)] +pub struct Support { + pub email: Option, + pub issues: Option, + pub forum: Option, + pub wiki: Option, + pub source: Option, + pub docs: Option, + pub irc: Option, + pub chat: Option, + pub rss: Option, + pub security: Option, +} + +/// Funding link. +#[derive(Debug, Clone)] +pub struct Funding { + pub url: Option, + pub funding_type: Option, +} + +/// Version alias entry for root packages. +#[derive(Debug, Clone)] +pub struct VersionAlias { + pub package: String, + pub version: String, + pub alias: String, + pub alias_normalized: String, +} + +/// Core package data covering `BasePackage` + `Package` fields. +/// Corresponds to `Composer\Package\Package` (implements `PackageInterface`). +#[derive(Debug, Clone)] +pub struct PackageData { + // BasePackage fields + pub name: String, + pub pretty_name: String, + + // Package fields + pub version: String, + pub pretty_version: String, + pub package_type: String, + pub target_dir: Option, + + // source + pub source_type: Option, + pub source_url: Option, + pub source_reference: Option, + + // dist + pub dist_type: Option, + pub dist_url: Option, + pub dist_reference: Option, + pub dist_sha1_checksum: Option, + + pub release_date: Option, + pub extra: BTreeMap, + pub binaries: Vec, + pub dev: bool, + pub stability: Stability, + pub notification_url: Option, + + // dependency links + pub requires: BTreeMap, + pub conflicts: BTreeMap, + pub provides: BTreeMap, + pub replaces: BTreeMap, + pub dev_requires: BTreeMap, + pub suggests: BTreeMap, + + // autoload + pub autoload: AutoloadRules, + pub dev_autoload: AutoloadRules, + + pub is_default_branch: bool, +} + +/// Package with full metadata (description, authors, license, etc.). +/// Corresponds to `Composer\Package\CompletePackage`. +#[derive(Debug, Clone)] +pub struct CompletePackageData { + pub package: PackageData, + + pub description: Option, + pub homepage: Option, + pub license: Vec, + pub keywords: Vec, + pub authors: Vec, + pub scripts: BTreeMap>, + pub support: Support, + pub funding: Vec, + pub repositories: Vec, + /// `None` = not abandoned, `Some("")` = abandoned, `Some(pkg)` = replaced by pkg. + pub abandoned: Option, + pub archive_name: Option, + pub archive_excludes: Vec, +} + +/// The root project package with project-level configuration. +/// Corresponds to `Composer\Package\RootPackage`. +#[derive(Debug, Clone)] +pub struct RootPackageData { + pub complete: CompletePackageData, + + pub minimum_stability: Stability, + pub prefer_stable: bool, + pub stability_flags: BTreeMap, + pub config: BTreeMap, + pub references: BTreeMap, + pub aliases: Vec, +} + +/// Accessor for `PackageData` fields. +/// Corresponds to `Composer\Package\PackageInterface`. +pub trait Package { + fn name(&self) -> &str; + fn pretty_name(&self) -> &str; + fn version(&self) -> &str; + fn pretty_version(&self) -> &str; + fn package_type(&self) -> &str; + fn target_dir(&self) -> Option<&str>; + fn source_type(&self) -> Option<&str>; + fn source_url(&self) -> Option<&str>; + fn source_reference(&self) -> Option<&str>; + fn dist_type(&self) -> Option<&str>; + fn dist_url(&self) -> Option<&str>; + fn dist_reference(&self) -> Option<&str>; + fn dist_sha1_checksum(&self) -> Option<&str>; + fn release_date(&self) -> Option<&str>; + fn extra(&self) -> &BTreeMap; + fn binaries(&self) -> &[String]; + fn is_dev(&self) -> bool; + fn stability(&self) -> Stability; + fn notification_url(&self) -> Option<&str>; + fn requires(&self) -> &BTreeMap; + fn conflicts(&self) -> &BTreeMap; + fn provides(&self) -> &BTreeMap; + fn replaces(&self) -> &BTreeMap; + fn dev_requires(&self) -> &BTreeMap; + fn suggests(&self) -> &BTreeMap; + fn autoload(&self) -> &AutoloadRules; + fn dev_autoload(&self) -> &AutoloadRules; + fn is_default_branch(&self) -> bool; +} + +/// Accessor for `CompletePackageData` fields. +/// Corresponds to `Composer\Package\CompletePackageInterface`. +pub trait CompletePackage: Package { + fn description(&self) -> Option<&str>; + fn homepage(&self) -> Option<&str>; + fn license(&self) -> &[String]; + fn keywords(&self) -> &[String]; + fn authors(&self) -> &[Author]; + fn scripts(&self) -> &BTreeMap>; + fn support(&self) -> &Support; + fn funding(&self) -> &[Funding]; + fn repositories(&self) -> &[serde_json::Value]; + fn abandoned(&self) -> Option<&str>; + fn archive_name(&self) -> Option<&str>; + fn archive_excludes(&self) -> &[String]; +} + +/// Accessor for `RootPackageData` fields. +/// Corresponds to `Composer\Package\RootPackageInterface`. +pub trait RootPackage: CompletePackage { + fn minimum_stability(&self) -> Stability; + fn prefer_stable(&self) -> bool; + fn stability_flags(&self) -> &BTreeMap; + fn config(&self) -> &BTreeMap; + fn references(&self) -> &BTreeMap; + fn aliases(&self) -> &[VersionAlias]; +} + +// ────────────────────────────────────────────── +// Delegation macros +// ────────────────────────────────────────────── + +/// Implements `Package` trait by delegating to an inner `PackageData` field. +macro_rules! delegate_package { + ($type:ty => $($path:ident).+) => { + impl Package for $type { + fn name(&self) -> &str { &self.$($path).+.name } + fn pretty_name(&self) -> &str { &self.$($path).+.pretty_name } + fn version(&self) -> &str { &self.$($path).+.version } + fn pretty_version(&self) -> &str { &self.$($path).+.pretty_version } + fn package_type(&self) -> &str { &self.$($path).+.package_type } + fn target_dir(&self) -> Option<&str> { self.$($path).+.target_dir.as_deref() } + fn source_type(&self) -> Option<&str> { self.$($path).+.source_type.as_deref() } + fn source_url(&self) -> Option<&str> { self.$($path).+.source_url.as_deref() } + fn source_reference(&self) -> Option<&str> { self.$($path).+.source_reference.as_deref() } + fn dist_type(&self) -> Option<&str> { self.$($path).+.dist_type.as_deref() } + fn dist_url(&self) -> Option<&str> { self.$($path).+.dist_url.as_deref() } + fn dist_reference(&self) -> Option<&str> { self.$($path).+.dist_reference.as_deref() } + fn dist_sha1_checksum(&self) -> Option<&str> { self.$($path).+.dist_sha1_checksum.as_deref() } + fn release_date(&self) -> Option<&str> { self.$($path).+.release_date.as_deref() } + fn extra(&self) -> &BTreeMap { &self.$($path).+.extra } + fn binaries(&self) -> &[String] { &self.$($path).+.binaries } + fn is_dev(&self) -> bool { self.$($path).+.dev } + fn stability(&self) -> Stability { self.$($path).+.stability } + fn notification_url(&self) -> Option<&str> { self.$($path).+.notification_url.as_deref() } + fn requires(&self) -> &BTreeMap { &self.$($path).+.requires } + fn conflicts(&self) -> &BTreeMap { &self.$($path).+.conflicts } + fn provides(&self) -> &BTreeMap { &self.$($path).+.provides } + fn replaces(&self) -> &BTreeMap { &self.$($path).+.replaces } + fn dev_requires(&self) -> &BTreeMap { &self.$($path).+.dev_requires } + fn suggests(&self) -> &BTreeMap { &self.$($path).+.suggests } + fn autoload(&self) -> &AutoloadRules { &self.$($path).+.autoload } + fn dev_autoload(&self) -> &AutoloadRules { &self.$($path).+.dev_autoload } + fn is_default_branch(&self) -> bool { self.$($path).+.is_default_branch } + } + }; +} + +/// Implements `CompletePackage` trait by delegating to an inner `CompletePackageData` field. +macro_rules! delegate_complete_package { + ($type:ty => $($path:ident).+) => { + impl CompletePackage for $type { + fn description(&self) -> Option<&str> { self.$($path).+.description.as_deref() } + fn homepage(&self) -> Option<&str> { self.$($path).+.homepage.as_deref() } + fn license(&self) -> &[String] { &self.$($path).+.license } + fn keywords(&self) -> &[String] { &self.$($path).+.keywords } + fn authors(&self) -> &[Author] { &self.$($path).+.authors } + fn scripts(&self) -> &BTreeMap> { &self.$($path).+.scripts } + fn support(&self) -> &Support { &self.$($path).+.support } + fn funding(&self) -> &[Funding] { &self.$($path).+.funding } + fn repositories(&self) -> &[serde_json::Value] { &self.$($path).+.repositories } + fn abandoned(&self) -> Option<&str> { self.$($path).+.abandoned.as_deref() } + fn archive_name(&self) -> Option<&str> { self.$($path).+.archive_name.as_deref() } + fn archive_excludes(&self) -> &[String] { &self.$($path).+.archive_excludes } + } + }; +} + +impl Package for PackageData { + fn name(&self) -> &str { + &self.name + } + fn pretty_name(&self) -> &str { + &self.pretty_name + } + fn version(&self) -> &str { + &self.version + } + fn pretty_version(&self) -> &str { + &self.pretty_version + } + fn package_type(&self) -> &str { + &self.package_type + } + fn target_dir(&self) -> Option<&str> { + self.target_dir.as_deref() + } + fn source_type(&self) -> Option<&str> { + self.source_type.as_deref() + } + fn source_url(&self) -> Option<&str> { + self.source_url.as_deref() + } + fn source_reference(&self) -> Option<&str> { + self.source_reference.as_deref() + } + fn dist_type(&self) -> Option<&str> { + self.dist_type.as_deref() + } + fn dist_url(&self) -> Option<&str> { + self.dist_url.as_deref() + } + fn dist_reference(&self) -> Option<&str> { + self.dist_reference.as_deref() + } + fn dist_sha1_checksum(&self) -> Option<&str> { + self.dist_sha1_checksum.as_deref() + } + fn release_date(&self) -> Option<&str> { + self.release_date.as_deref() + } + fn extra(&self) -> &BTreeMap { + &self.extra + } + fn binaries(&self) -> &[String] { + &self.binaries + } + fn is_dev(&self) -> bool { + self.dev + } + fn stability(&self) -> Stability { + self.stability + } + fn notification_url(&self) -> Option<&str> { + self.notification_url.as_deref() + } + fn requires(&self) -> &BTreeMap { + &self.requires + } + fn conflicts(&self) -> &BTreeMap { + &self.conflicts + } + fn provides(&self) -> &BTreeMap { + &self.provides + } + fn replaces(&self) -> &BTreeMap { + &self.replaces + } + fn dev_requires(&self) -> &BTreeMap { + &self.dev_requires + } + fn suggests(&self) -> &BTreeMap { + &self.suggests + } + fn autoload(&self) -> &AutoloadRules { + &self.autoload + } + fn dev_autoload(&self) -> &AutoloadRules { + &self.dev_autoload + } + fn is_default_branch(&self) -> bool { + self.is_default_branch + } +} + +impl CompletePackage for CompletePackageData { + fn description(&self) -> Option<&str> { + self.description.as_deref() + } + fn homepage(&self) -> Option<&str> { + self.homepage.as_deref() + } + fn license(&self) -> &[String] { + &self.license + } + fn keywords(&self) -> &[String] { + &self.keywords + } + fn authors(&self) -> &[Author] { + &self.authors + } + fn scripts(&self) -> &BTreeMap> { + &self.scripts + } + fn support(&self) -> &Support { + &self.support + } + fn funding(&self) -> &[Funding] { + &self.funding + } + fn repositories(&self) -> &[serde_json::Value] { + &self.repositories + } + fn abandoned(&self) -> Option<&str> { + self.abandoned.as_deref() + } + fn archive_name(&self) -> Option<&str> { + self.archive_name.as_deref() + } + fn archive_excludes(&self) -> &[String] { + &self.archive_excludes + } +} + +impl RootPackage for RootPackageData { + fn minimum_stability(&self) -> Stability { + self.minimum_stability + } + fn prefer_stable(&self) -> bool { + self.prefer_stable + } + fn stability_flags(&self) -> &BTreeMap { + &self.stability_flags + } + fn config(&self) -> &BTreeMap { + &self.config + } + fn references(&self) -> &BTreeMap { + &self.references + } + fn aliases(&self) -> &[VersionAlias] { + &self.aliases + } +} + +// CompletePackageData delegates Package → inner PackageData +delegate_package!(CompletePackageData => package); + +// RootPackageData delegates Package → inner CompletePackageData → PackageData +delegate_package!(RootPackageData => complete.package); + +// RootPackageData delegates CompletePackage → inner CompletePackageData +delegate_complete_package!(RootPackageData => complete); + +/// Unstructured representation of a composer.json file. +/// Used by `init` and `create-project` to write a new composer.json. +/// Unlike the typed hierarchy above, all fields live at a single level +/// and map directly to the JSON keys via serde. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawPackageData { + pub name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub package_type: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub homepage: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authors: Vec, + + #[serde(rename = "minimum-stability", skip_serializing_if = "Option::is_none")] + pub minimum_stability: Option, + + #[serde(default)] + pub require: BTreeMap, + + #[serde( + rename = "require-dev", + default, + skip_serializing_if = "BTreeMap::is_empty" + )] + pub require_dev: BTreeMap, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub repositories: Vec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub autoload: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bin: Vec, + + #[serde(flatten)] + pub extra_fields: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawAuthor { + pub name: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawAutoload { + #[serde(rename = "psr-4")] + pub psr4: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawRepository { + #[serde(rename = "type")] + pub repo_type: String, + pub url: String, +} + +impl RawPackageData { + pub fn new(name: String) -> Self { + Self { + name, + description: None, + package_type: None, + homepage: None, + license: None, + authors: Vec::new(), + minimum_stability: None, + require: BTreeMap::new(), + require_dev: BTreeMap::new(), + repositories: Vec::new(), + autoload: None, + bin: Vec::new(), + extra_fields: BTreeMap::new(), + } + } +} + +pub fn read_from_file(path: &Path) -> anyhow::Result { + let content = fs::read_to_string(path)?; + let data: RawPackageData = serde_json::from_str(&content)?; + Ok(data) +} + +pub fn to_json_pretty(value: &impl Serialize) -> serde_json::Result { + let formatter = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut buf = Vec::new(); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, formatter); + value.serialize(&mut ser)?; + let mut json = String::from_utf8(buf).expect("serde_json produces valid UTF-8"); + json.push('\n'); + Ok(json) +} + +pub fn write_to_file(value: &impl Serialize, path: &Path) -> anyhow::Result<()> { + let json = to_json_pretty(value)?; + fs::write(path, json)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_minimal_json() { + let raw = RawPackageData::new("test/pkg".to_string()); + let json = to_json_pretty(&raw).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["name"], "test/pkg"); + assert!(parsed["require"].is_object()); + assert!(parsed.get("description").is_none()); + assert!(parsed.get("type").is_none()); + assert!(parsed.get("authors").is_none()); + assert!(parsed.get("require-dev").is_none()); + assert!(parsed.get("autoload").is_none()); + } + + #[test] + fn raw_full_json() { + let mut raw = RawPackageData::new("acme/full".to_string()); + raw.description = Some("A full package".to_string()); + raw.package_type = Some("library".to_string()); + raw.homepage = Some("https://example.com".to_string()); + raw.license = Some("MIT".to_string()); + raw.authors = vec![RawAuthor { + name: "Jane Doe".to_string(), + email: Some("jane@example.com".to_string()), + }]; + raw.minimum_stability = Some("dev".to_string()); + raw.require.insert("php".to_string(), ">=8.1".to_string()); + raw.require_dev + .insert("phpunit/phpunit".to_string(), "^10.0".to_string()); + raw.repositories = vec![RawRepository { + repo_type: "vcs".to_string(), + url: "https://github.com/acme/repo".to_string(), + }]; + + let mut psr4 = BTreeMap::new(); + psr4.insert("Acme\\Full\\".to_string(), "src/".to_string()); + raw.autoload = Some(RawAutoload { psr4 }); + + let json = to_json_pretty(&raw).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed["name"], "acme/full"); + assert_eq!(parsed["description"], "A full package"); + assert_eq!(parsed["type"], "library"); + assert_eq!(parsed["homepage"], "https://example.com"); + assert_eq!(parsed["license"], "MIT"); + assert_eq!(parsed["minimum-stability"], "dev"); + assert_eq!(parsed["authors"][0]["name"], "Jane Doe"); + assert_eq!(parsed["authors"][0]["email"], "jane@example.com"); + assert_eq!(parsed["require"]["php"], ">=8.1"); + assert_eq!(parsed["require-dev"]["phpunit/phpunit"], "^10.0"); + assert_eq!(parsed["repositories"][0]["type"], "vcs"); + assert_eq!(parsed["autoload"]["psr-4"]["Acme\\Full\\"], "src/"); + } + + #[test] + fn raw_deserialize_minimal() { + let json = r#"{"name": "test/pkg"}"#; + let raw: RawPackageData = serde_json::from_str(json).unwrap(); + assert_eq!(raw.name, "test/pkg"); + assert!(raw.description.is_none()); + assert!(raw.require.is_empty()); + assert!(raw.require_dev.is_empty()); + assert!(raw.authors.is_empty()); + assert!(raw.extra_fields.is_empty()); + } + + #[test] + fn raw_roundtrip_preserves_all_fields() { + let mut raw = RawPackageData::new("acme/roundtrip".to_string()); + raw.description = Some("Test roundtrip".to_string()); + raw.require.insert("php".to_string(), ">=8.1".to_string()); + raw.require_dev + .insert("phpunit/phpunit".to_string(), "^10.0".to_string()); + + let json1 = to_json_pretty(&raw).unwrap(); + let deserialized: RawPackageData = serde_json::from_str(&json1).unwrap(); + let json2 = to_json_pretty(&deserialized).unwrap(); + assert_eq!(json1, json2); + } + + #[test] + fn raw_extra_fields_preserved() { + let json = r#"{ + "name": "test/extra", + "require": {}, + "scripts": {"post-install-cmd": ["echo hello"]}, + "config": {"sort-packages": true}, + "extra": {"custom-key": "custom-value"} + }"#; + let raw: RawPackageData = serde_json::from_str(json).unwrap(); + assert_eq!(raw.name, "test/extra"); + assert!(raw.extra_fields.contains_key("scripts")); + assert!(raw.extra_fields.contains_key("config")); + assert!(raw.extra_fields.contains_key("extra")); + + // Roundtrip: extra fields should be preserved in output + let output = to_json_pretty(&raw).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(parsed["scripts"].is_object()); + assert!(parsed["config"].is_object()); + assert!(parsed["extra"].is_object()); + } + + #[test] + fn raw_read_from_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("composer.json"); + let content = r#"{"name": "test/file", "require": {"php": ">=8.0"}}"#; + std::fs::write(&path, content).unwrap(); + + let raw = read_from_file(&path).unwrap(); + assert_eq!(raw.name, "test/file"); + assert_eq!(raw.require.get("php").unwrap(), ">=8.0"); + } + + #[test] + fn raw_none_fields_omitted() { + let raw = RawPackageData::new("test/empty".to_string()); + let json = to_json_pretty(&raw).unwrap(); + + assert!(!json.contains("\"description\"")); + assert!(!json.contains("\"type\"")); + assert!(!json.contains("\"homepage\"")); + assert!(!json.contains("\"license\"")); + assert!(!json.contains("\"authors\"")); + assert!(!json.contains("\"minimum-stability\"")); + assert!(!json.contains("\"require-dev\"")); + assert!(!json.contains("\"repositories\"")); + assert!(!json.contains("\"autoload\"")); + } +} diff --git a/crates/mozart-core/src/platform.rs b/crates/mozart-core/src/platform.rs new file mode 100644 index 0000000..c1f187f --- /dev/null +++ b/crates/mozart-core/src/platform.rs @@ -0,0 +1,351 @@ +// Shared platform detection module. +// +// Provides detection of the PHP environment (version, extensions, capabilities) +// and helpers for identifying platform package names (php, ext-*, lib-*, etc.). + +// ─── Data structures ───────────────────────────────────────────────────────── + +/// A detected platform package with its name and version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlatformPackage { + pub name: String, + pub version: String, +} + +// ─── Classification ────────────────────────────────────────────────────────── + +/// Returns true if the package name is a Composer platform package. +/// +/// Platform packages include: php, php-*, ext-*, lib-*, composer, +/// composer-plugin-api, composer-runtime-api. +pub fn is_platform_package(name: &str) -> bool { + let lower = name.to_lowercase(); + lower == "php" + || lower.starts_with("php-") + || lower.starts_with("ext-") + || lower.starts_with("lib-") + || lower == "composer" + || lower == "composer-plugin-api" + || lower == "composer-runtime-api" +} + +// ─── Detection ─────────────────────────────────────────────────────────────── + +/// Detect all platform packages by running a single PHP invocation. +/// +/// Returns an empty vec if PHP is not found or not executable. +pub fn detect_platform() -> Vec { + let php_script = concat!( + "echo 'PHP_VERSION:' . PHP_VERSION . PHP_EOL;", + "echo 'PHP_INT_SIZE:' . PHP_INT_SIZE . PHP_EOL;", + "echo 'PHP_DEBUG:' . (PHP_DEBUG ? '1' : '0') . PHP_EOL;", + "echo 'PHP_ZTS:' . (defined('PHP_ZTS') && PHP_ZTS ? '1' : '0') . PHP_EOL;", + "echo 'IPV6:' . ((defined('AF_INET6') || @inet_pton('::') !== false) ? '1' : '0') . PHP_EOL;", + "echo 'EXTENSIONS:' . PHP_EOL;", + "foreach(get_loaded_extensions() as $e) { echo $e . ':' . (phpversion($e) ?: '0') . PHP_EOL; }" + ); + + let output = match std::process::Command::new("php") + .arg("-r") + .arg(php_script) + .output() + { + Ok(o) => o, + Err(_) => return vec![], + }; + + if !output.status.success() { + return vec![]; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + parse_platform_info(&stdout) +} + +/// Parse the output of the PHP platform detection script. +/// +/// Exposed for testing purposes. +pub fn parse_platform_info(output: &str) -> Vec { + let mut packages: Vec = Vec::new(); + + let mut php_version = String::new(); + let mut int_size: u8 = 0; + let mut php_debug = false; + let mut php_zts = false; + let mut php_ipv6 = false; + let mut in_extensions = false; + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if let Some(v) = line.strip_prefix("PHP_VERSION:") { + php_version = v.to_string(); + continue; + } + if let Some(v) = line.strip_prefix("PHP_INT_SIZE:") { + int_size = v.parse().unwrap_or(0); + continue; + } + if let Some(v) = line.strip_prefix("PHP_DEBUG:") { + php_debug = v == "1"; + continue; + } + if let Some(v) = line.strip_prefix("PHP_ZTS:") { + php_zts = v == "1"; + continue; + } + if let Some(v) = line.strip_prefix("IPV6:") { + php_ipv6 = v == "1"; + continue; + } + if line == "EXTENSIONS:" { + in_extensions = true; + continue; + } + + if in_extensions { + // Format: ExtensionName:version + if let Some(colon_pos) = line.find(':') { + let ext_name = line[..colon_pos].trim().to_lowercase(); + let ext_version = line[colon_pos + 1..].trim(); + // Normalize: if version is "0", "false", or empty, use the PHP version + let version = + if ext_version.is_empty() || ext_version == "0" || ext_version == "false" { + if php_version.is_empty() { + "0.0.0".to_string() + } else { + php_version.clone() + } + } else { + ext_version.to_string() + }; + packages.push(PlatformPackage { + name: format!("ext-{ext_name}"), + version, + }); + } + } + } + + // Build the base php entry first (so it's easy to find) + if !php_version.is_empty() { + let mut result: Vec = Vec::new(); + + result.push(PlatformPackage { + name: "php".to_string(), + version: php_version.clone(), + }); + + if int_size == 8 { + result.push(PlatformPackage { + name: "php-64bit".to_string(), + version: php_version.clone(), + }); + } + + if php_debug { + result.push(PlatformPackage { + name: "php-debug".to_string(), + version: php_version.clone(), + }); + } + + if php_zts { + result.push(PlatformPackage { + name: "php-zts".to_string(), + version: php_version.clone(), + }); + } + + if php_ipv6 { + result.push(PlatformPackage { + name: "php-ipv6".to_string(), + version: php_version.clone(), + }); + } + + result.extend(packages); + result + } else { + packages + } +} + +/// Try to detect the installed PHP version by running `php --version`. +pub fn detect_php_version() -> Option { + let output = std::process::Command::new("php") + .arg("--version") + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + // Parse "PHP 8.2.1 (cli) ..." → "8.2.1" + let first_line = stdout.lines().next()?; + let parts: Vec<&str> = first_line.split_whitespace().collect(); + if parts.len() >= 2 && parts[0] == "PHP" { + Some(parts[1].to_string()) + } else { + None + } +} + +/// Try to detect PHP extensions by running `php -m`. +pub fn detect_php_extensions() -> Vec { + let output = match std::process::Command::new("php").arg("-m").output() { + Ok(o) => o, + Err(_) => return vec![], + }; + + if !output.status.success() { + return vec![]; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .lines() + .filter(|line| { + let l = line.trim(); + !l.is_empty() + && !l.starts_with('[') + && l.chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') + }) + .map(|l| l.trim().to_lowercase()) + .collect() +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_platform_package_php() { + assert!(is_platform_package("php")); + assert!(is_platform_package("PHP")); + } + + #[test] + fn test_is_platform_package_php_variants() { + assert!(is_platform_package("php-64bit")); + assert!(is_platform_package("php-debug")); + assert!(is_platform_package("php-zts")); + assert!(is_platform_package("php-ipv6")); + } + + #[test] + fn test_is_platform_package_ext() { + assert!(is_platform_package("ext-json")); + assert!(is_platform_package("ext-mbstring")); + assert!(is_platform_package("ext-ctype")); + } + + #[test] + fn test_is_platform_package_lib() { + assert!(is_platform_package("lib-pcre")); + assert!(is_platform_package("lib-curl")); + } + + #[test] + fn test_is_platform_package_composer() { + assert!(is_platform_package("composer")); + assert!(is_platform_package("composer-plugin-api")); + assert!(is_platform_package("composer-runtime-api")); + } + + #[test] + fn test_is_platform_package_not_platform() { + assert!(!is_platform_package("monolog/monolog")); + assert!(!is_platform_package("psr/log")); + assert!(!is_platform_package("symfony/console")); + assert!(!is_platform_package("vendor/package")); + } + + #[test] + fn test_parse_platform_info_basic() { + let output = "PHP_VERSION:8.2.1\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:1\nEXTENSIONS:\njson:8.2.1\nctype:8.2.1\n"; + let packages = parse_platform_info(output); + + let php = packages.iter().find(|p| p.name == "php"); + assert!(php.is_some()); + assert_eq!(php.unwrap().version, "8.2.1"); + + let php64 = packages.iter().find(|p| p.name == "php-64bit"); + assert!(php64.is_some(), "PHP_INT_SIZE=8 should produce php-64bit"); + + let ipv6 = packages.iter().find(|p| p.name == "php-ipv6"); + assert!(ipv6.is_some()); + + let ext_json = packages.iter().find(|p| p.name == "ext-json"); + assert!(ext_json.is_some()); + assert_eq!(ext_json.unwrap().version, "8.2.1"); + + let ext_ctype = packages.iter().find(|p| p.name == "ext-ctype"); + assert!(ext_ctype.is_some()); + } + + #[test] + fn test_parse_platform_info_no_debug_no_zts() { + let output = + "PHP_VERSION:8.1.0\nPHP_INT_SIZE:4\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\n"; + let packages = parse_platform_info(output); + + assert!(packages.iter().any(|p| p.name == "php")); + assert!(!packages.iter().any(|p| p.name == "php-64bit")); + assert!(!packages.iter().any(|p| p.name == "php-debug")); + assert!(!packages.iter().any(|p| p.name == "php-zts")); + assert!(!packages.iter().any(|p| p.name == "php-ipv6")); + } + + #[test] + fn test_parse_platform_info_debug_and_zts() { + let output = + "PHP_VERSION:8.3.0\nPHP_INT_SIZE:8\nPHP_DEBUG:1\nPHP_ZTS:1\nIPV6:0\nEXTENSIONS:\n"; + let packages = parse_platform_info(output); + + assert!(packages.iter().any(|p| p.name == "php-debug")); + assert!(packages.iter().any(|p| p.name == "php-zts")); + } + + #[test] + fn test_parse_platform_info_extension_version_zero() { + // Extensions returning version "0" should fall back to PHP version + let output = "PHP_VERSION:8.2.5\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\nCore:0\n"; + let packages = parse_platform_info(output); + + let ext_core = packages.iter().find(|p| p.name == "ext-core"); + assert!(ext_core.is_some()); + assert_eq!( + ext_core.unwrap().version, + "8.2.5", + "version '0' should fall back to PHP version" + ); + } + + #[test] + fn test_parse_platform_info_no_php() { + // If PHP_VERSION is missing, only extensions are returned + let output = "EXTENSIONS:\njson:1.7\n"; + let packages = parse_platform_info(output); + + assert!(!packages.iter().any(|p| p.name == "php")); + assert!(packages.iter().any(|p| p.name == "ext-json")); + } + + #[test] + fn test_parse_platform_info_extension_names_lowercased() { + let output = "PHP_VERSION:8.0.0\nPHP_INT_SIZE:8\nPHP_DEBUG:0\nPHP_ZTS:0\nIPV6:0\nEXTENSIONS:\nJSON:8.0.0\nMbstring:8.0.0\n"; + let packages = parse_platform_info(output); + + assert!(packages.iter().any(|p| p.name == "ext-json")); + assert!(packages.iter().any(|p| p.name == "ext-mbstring")); + } +} diff --git a/crates/mozart-core/src/suggest.rs b/crates/mozart-core/src/suggest.rs new file mode 100644 index 0000000..9311fdb --- /dev/null +++ b/crates/mozart-core/src/suggest.rs @@ -0,0 +1,220 @@ +//! Fuzzy package name suggestions using Levenshtein distance. +//! +//! Used to provide "Did you mean ...?" hints when a user types a package name +//! that does not exist in the installed packages or in the require/require-dev +//! sections of composer.json. + +/// Compute the Levenshtein edit distance between two strings. +/// +/// This is a standard dynamic-programming implementation that runs in O(m*n) +/// time and O(min(m,n)) space. +pub fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + + let m = a.len(); + let n = b.len(); + + if m == 0 { + return n; + } + if n == 0 { + return m; + } + + // Use two alternating rows to save memory. + let mut prev: Vec = (0..=n).collect(); + let mut curr: Vec = vec![0; n + 1]; + + for i in 1..=m { + curr[0] = i; + for j in 1..=n { + let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 }; + curr[j] = (prev[j] + 1) // deletion + .min(curr[j - 1] + 1) // insertion + .min(prev[j - 1] + cost); // substitution + } + std::mem::swap(&mut prev, &mut curr); + } + + prev[n] +} + +/// Maximum edit distance for a suggestion to be considered "similar". +/// +/// Packages with Levenshtein distance greater than this threshold are not +/// returned as suggestions. +const MAX_DISTANCE: usize = 5; + +/// Find package names from `candidates` that are similar to `query`. +/// +/// Returns a list of `(distance, name)` pairs sorted by ascending distance, +/// then ascending name for stability. Only candidates with a Levenshtein +/// distance <= [`MAX_DISTANCE`] are returned. +pub fn find_similar<'a>( + query: &str, + candidates: impl Iterator, +) -> Vec<(usize, &'a str)> { + let query_lower = query.to_lowercase(); + let mut results: Vec<(usize, &'a str)> = candidates + .filter_map(|name| { + let dist = levenshtein(&query_lower, &name.to_lowercase()); + if dist <= MAX_DISTANCE && dist > 0 { + Some((dist, name)) + } else { + None + } + }) + .collect(); + + results.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1))); + results +} + +/// Format a "Did you mean ...?" message from a list of suggestions. +/// +/// Returns `None` when `suggestions` is empty. +/// +/// # Examples +/// +/// ``` +/// use mozart_core::suggest::format_did_you_mean; +/// let msg = format_did_you_mean(&["psr/log", "psr/cache"]); +/// assert!(msg.unwrap().contains("Did you mean")); +/// ``` +pub fn format_did_you_mean(suggestions: &[&str]) -> Option { + if suggestions.is_empty() { + return None; + } + + let formatted = suggestions + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(" or "); + + Some(format!("Did you mean {}?", formatted)) +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── levenshtein ─────────────────────────────────────────────────────────── + + #[test] + fn test_levenshtein_identical() { + assert_eq!(levenshtein("psr/log", "psr/log"), 0); + } + + #[test] + fn test_levenshtein_empty_left() { + assert_eq!(levenshtein("", "abc"), 3); + } + + #[test] + fn test_levenshtein_empty_right() { + assert_eq!(levenshtein("abc", ""), 3); + } + + #[test] + fn test_levenshtein_both_empty() { + assert_eq!(levenshtein("", ""), 0); + } + + #[test] + fn test_levenshtein_single_insertion() { + assert_eq!(levenshtein("psr/log", "psr/logs"), 1); + } + + #[test] + fn test_levenshtein_single_deletion() { + assert_eq!(levenshtein("psr/logs", "psr/log"), 1); + } + + #[test] + fn test_levenshtein_single_substitution() { + assert_eq!(levenshtein("psr/log", "psr/lag"), 1); + } + + #[test] + fn test_levenshtein_completely_different() { + assert_eq!(levenshtein("abc", "xyz"), 3); + } + + #[test] + fn test_levenshtein_package_names() { + // "monolog/monolog" vs "monolong/monolog" — 1 insertion + assert_eq!(levenshtein("monolog/monolog", "monolong/monolog"), 1); + } + + // ── find_similar ────────────────────────────────────────────────────────── + + #[test] + fn test_find_similar_returns_close_matches() { + let candidates = ["psr/log", "psr/cache", "monolog/monolog", "symfony/console"]; + let results = find_similar("psr/lod", candidates.iter().copied()); + assert!(!results.is_empty()); + // "psr/log" has distance 1 from "psr/lod" + assert_eq!(results[0].1, "psr/log"); + assert_eq!(results[0].0, 1); + } + + #[test] + fn test_find_similar_excludes_exact_match() { + let candidates = ["psr/log", "psr/cache"]; + // Exact match should not appear (distance == 0) + let results = find_similar("psr/log", candidates.iter().copied()); + assert!(!results.iter().any(|(_, name)| *name == "psr/log")); + } + + #[test] + fn test_find_similar_excludes_too_distant() { + let candidates = ["completely/different", "another/package"]; + let results = find_similar("psr/log", candidates.iter().copied()); + // All candidates are more than MAX_DISTANCE away + assert!(results.is_empty()); + } + + #[test] + fn test_find_similar_sorted_by_distance() { + let candidates = ["psr/log", "psr/logs", "psr/logsx"]; + // "psr/lod" -> "psr/log" distance 1, "psr/logs" distance 2, "psr/logsx" distance 3 + let results = find_similar("psr/lod", candidates.iter().copied()); + if results.len() >= 2 { + assert!(results[0].0 <= results[1].0); + } + } + + #[test] + fn test_find_similar_case_insensitive() { + let candidates = ["PSR/Log"]; + let results = find_similar("psr/log", candidates.iter().copied()); + // "psr/log" vs "psr/log" (both lowercased) = distance 0, so excluded + assert!(results.is_empty()); + } + + // ── format_did_you_mean ─────────────────────────────────────────────────── + + #[test] + fn test_format_did_you_mean_empty() { + assert!(format_did_you_mean(&[]).is_none()); + } + + #[test] + fn test_format_did_you_mean_single() { + let msg = format_did_you_mean(&["psr/log"]).unwrap(); + assert_eq!(msg, "Did you mean \"psr/log\"?"); + } + + #[test] + fn test_format_did_you_mean_multiple() { + let msg = format_did_you_mean(&["psr/log", "psr/cache"]).unwrap(); + assert!(msg.contains("Did you mean")); + assert!(msg.contains("\"psr/log\"")); + assert!(msg.contains("\"psr/cache\"")); + assert!(msg.contains(" or ")); + } +} diff --git a/crates/mozart-core/src/validation.rs b/crates/mozart-core/src/validation.rs new file mode 100644 index 0000000..7f946ae --- /dev/null +++ b/crates/mozart-core/src/validation.rs @@ -0,0 +1,226 @@ +use regex::Regex; +use std::sync::LazyLock; + +static PACKAGE_NAME_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[a-z0-9]([_.\-]?[a-z0-9]+)*/[a-z0-9](([_.]|\-{1,2})?[a-z0-9]+)*$").unwrap() +}); + +static AUTHOR_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?P[- .,\pL\pN\pM''\x{201C}\x{201D}()]+)(?:\s+<(?P.+?)>)?$").unwrap() +}); + +static AUTOLOAD_PATH_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[^/][A-Za-z0-9\-_/]+/$").unwrap()); + +static CAMEL_SPLIT_RE: LazyLock = + LazyLock::new(|| Regex::new(r"(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))").unwrap()); + +static SANITIZE_EDGES_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[_.\-]+|[_.\-]+$|[^a-z0-9_.\-]").unwrap()); + +static SANITIZE_REPEATS_RE: LazyLock = + LazyLock::new(|| Regex::new(r"([_.\-]){2,}").unwrap()); + +static NON_ALNUM_RE: LazyLock = LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9]").unwrap()); + +const VALID_STABILITIES: &[&str] = &["dev", "alpha", "beta", "rc", "stable"]; + +pub fn validate_package_name(name: &str) -> bool { + PACKAGE_NAME_RE.is_match(name) +} + +pub struct ParsedAuthor { + pub name: String, + pub email: Option, +} + +pub fn parse_author(input: &str) -> Result { + if let Some(caps) = AUTHOR_RE.captures(input) { + let name = caps.name("name").unwrap().as_str().trim().to_string(); + let email = caps.name("email").map(|m| m.as_str().to_string()); + Ok(ParsedAuthor { name, email }) + } else { + Err( + "Invalid author string. Must be in the formats: Jane Doe or John Smith " + .to_string(), + ) + } +} + +pub fn validate_stability(s: &str) -> bool { + VALID_STABILITIES.contains(&s.to_lowercase().as_str()) +} + +pub fn validate_license(s: &str) -> bool { + // TODO: check SPDX Identifier + !s.is_empty() +} + +pub fn validate_autoload_path(s: &str) -> bool { + AUTOLOAD_PATH_RE.is_match(s) +} + +pub fn namespace_from_package_name(package_name: &str) -> Option { + if package_name.is_empty() || !package_name.contains('/') { + return None; + } + + let parts: Vec = package_name + .split('/') + .map(|part| { + let replaced = NON_ALNUM_RE.replace_all(part, " "); + let words: Vec = replaced + .split_whitespace() + .map(|w| { + let mut chars = w.chars(); + match chars.next() { + Some(c) => c.to_uppercase().to_string() + &chars.collect::(), + None => String::new(), + } + }) + .collect(); + words.join("") + }) + .collect(); + + Some(parts.join("\\")) +} + +pub fn sanitize_package_name_component(name: &str) -> String { + // CamelCase → kebab-case + let name = CAMEL_SPLIT_RE.replace_all(name, "${1}${3}-${2}${4}"); + let name = name.to_lowercase(); + // Remove leading/trailing separators and non-alnum chars + let name = SANITIZE_EDGES_RE.replace_all(&name, ""); + // Collapse repeated separators + let name = SANITIZE_REPEATS_RE.replace_all(&name, "$1"); + name.to_string() +} + +pub fn parse_require_string(s: &str) -> Result<(String, String), String> { + // Formats: "foo/bar:^1.0", "foo/bar=^1.0", "foo/bar ^1.0" + let s = s.trim(); + + for sep in [':', '=', ' '] { + if let Some(pos) = s.find(sep) { + let name = s[..pos].trim(); + let version = s[pos + sep.len_utf8()..].trim(); + if !name.is_empty() && !version.is_empty() { + return Ok((name.to_string(), version.to_string())); + } + } + } + + Err(format!( + "Could not parse requirement \"{s}\". Expected format: vendor/package:version" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_package_names() { + assert!(validate_package_name("vendor/package")); + assert!(validate_package_name("my-vendor/my-package")); + assert!(validate_package_name("vendor/pkg123")); + assert!(validate_package_name("a/b")); + assert!(validate_package_name("vendor/my_package")); + assert!(validate_package_name("vendor/my.package")); + assert!(validate_package_name("vendor/my--package")); + } + + #[test] + fn test_invalid_package_names() { + assert!(!validate_package_name("novendor")); + assert!(!validate_package_name("/package")); + assert!(!validate_package_name("vendor/")); + assert!(!validate_package_name("Vendor/Package")); + assert!(!validate_package_name("vendor/pack age")); + assert!(!validate_package_name("")); + } + + #[test] + fn test_parse_author_name_and_email() { + let a = parse_author("John Smith ").unwrap(); + assert_eq!(a.name, "John Smith"); + assert_eq!(a.email.as_deref(), Some("john@example.com")); + } + + #[test] + fn test_parse_author_name_only() { + let a = parse_author("Jane Doe").unwrap(); + assert_eq!(a.name, "Jane Doe"); + assert!(a.email.is_none()); + } + + #[test] + fn test_parse_author_invalid() { + assert!(parse_author("").is_err()); + } + + #[test] + fn test_validate_stability() { + assert!(validate_stability("dev")); + assert!(validate_stability("alpha")); + assert!(validate_stability("beta")); + assert!(validate_stability("rc")); + assert!(validate_stability("stable")); + assert!(validate_stability("Dev")); + assert!(validate_stability("STABLE")); + assert!(!validate_stability("invalid")); + assert!(!validate_stability("")); + } + + #[test] + fn test_validate_autoload_path() { + assert!(validate_autoload_path("src/")); + assert!(validate_autoload_path("lib/src/")); + assert!(!validate_autoload_path("/src/")); + assert!(!validate_autoload_path("src")); + assert!(!validate_autoload_path("")); + } + + #[test] + fn test_namespace_from_package_name() { + assert_eq!( + namespace_from_package_name("acme/my-pkg"), + Some("Acme\\MyPkg".to_string()) + ); + assert_eq!( + namespace_from_package_name("new_projects.acme-extra/package-name"), + Some("NewProjectsAcmeExtra\\PackageName".to_string()) + ); + assert_eq!(namespace_from_package_name(""), None); + assert_eq!(namespace_from_package_name("novendor"), None); + } + + #[test] + fn test_sanitize_package_name_component() { + assert_eq!(sanitize_package_name_component("MyPackage"), "my-package"); + assert_eq!( + sanitize_package_name_component("CamelCaseTest"), + "camel-case-test" + ); + assert_eq!(sanitize_package_name_component("already-ok"), "already-ok"); + assert_eq!(sanitize_package_name_component("__bad__"), "bad"); + } + + #[test] + fn test_parse_require_string() { + let (name, ver) = parse_require_string("foo/bar:^1.0").unwrap(); + assert_eq!(name, "foo/bar"); + assert_eq!(ver, "^1.0"); + + let (name, ver) = parse_require_string("foo/bar=^1.0").unwrap(); + assert_eq!(name, "foo/bar"); + assert_eq!(ver, "^1.0"); + + let (name, ver) = parse_require_string("foo/bar ^1.0").unwrap(); + assert_eq!(name, "foo/bar"); + assert_eq!(ver, "^1.0"); + + assert!(parse_require_string("invalid").is_err()); + } +} diff --git a/crates/mozart-core/src/version_bumper.rs b/crates/mozart-core/src/version_bumper.rs new file mode 100644 index 0000000..43c21d6 --- /dev/null +++ b/crates/mozart-core/src/version_bumper.rs @@ -0,0 +1,667 @@ +/// Version constraint bumper. +/// +/// Given a constraint string (from composer.json) and the installed version +/// (from composer.lock), computes a new constraint string that raises the +/// lower bound to match the installed version. +/// +/// Returns `None` if no change is needed, or `Some(new_constraint)` if the +/// constraint should be updated. +pub fn bump_requirement( + constraint_str: &str, + pretty_version: &str, + version_normalized: Option<&str>, +) -> Option { + let constraint = constraint_str.trim(); + + // Strip and preserve stability flag (@dev, @beta, etc.) + let (constraint_body, stability_flag) = strip_stability_flag(constraint); + + // Dev constraints (dev-master, dev-main, etc.) are left unchanged + if constraint_body.trim().starts_with("dev-") { + return None; + } + + // Skip dev installed versions that have no alias + // An alias looks like "dev-master as 1.0.0" — the version string in the lock + // would be "dev-master" without " as ". + if pretty_version.starts_with("dev-") && !pretty_version.contains(" as ") { + return None; + } + if let Some(norm) = version_normalized + && norm.starts_with("dev-") + && !pretty_version.contains(" as ") + { + return None; + } + + // Resolve the actual version string to use for bumping. + // If the pretty_version contains an inline alias (e.g. "dev-master as 1.0.0"), + // take the alias target. Otherwise use pretty_version directly. + let installed_version = resolve_installed_version(pretty_version, version_normalized); + + // Handle OR constraints (^1.0 || ^2.0) + if constraint_body.contains("||") { + return bump_or_constraint(constraint_body, &installed_version, stability_flag); + } + + // Single constraint + bump_single(constraint_body.trim(), &installed_version, stability_flag) +} + +// ─── OR constraint handling ─────────────────────────────────────────────────── + +fn bump_or_constraint( + constraint_body: &str, + installed_version: &str, + stability_flag: Option<&str>, +) -> Option { + let parts: Vec<&str> = constraint_body.split("||").map(str::trim).collect(); + + // Determine which major the installed version belongs to + let installed_major = parse_major(installed_version); + + let mut changed = false; + let mut new_parts: Vec = Vec::new(); + + for part in &parts { + let part_trimmed = part.trim(); + // Determine the major range this disjunct covers + let part_major = constraint_major(part_trimmed); + + // Only bump the disjunct whose major matches the installed version's major + if part_major == installed_major { + if let Some(bumped) = bump_single(part_trimmed, installed_version, None) { + new_parts.push(bumped); + changed = true; + } else { + new_parts.push(part_trimmed.to_string()); + } + } else { + new_parts.push(part_trimmed.to_string()); + } + } + + if !changed { + return None; + } + + let joined = new_parts.join(" || "); + let result = append_stability_flag(&joined, stability_flag); + Some(result) +} + +// ─── Single constraint handling ─────────────────────────────────────────────── + +fn bump_single( + constraint: &str, + installed_version: &str, + stability_flag: Option<&str>, +) -> Option { + // AND constraints (space-separated multiple operators like ">=1.0 <2.0" or + // comma-separated like ">=1.0,<2.0") are not supported for bumping — leave unchanged. + // We detect them by checking for a space or comma after the version spec begins. + // Quick check: if the constraint contains a space (ignoring leading operators), + // it's likely a multi-part AND constraint. + let after_op = constraint + .trim_start_matches('^') + .trim_start_matches('~') + .trim_start_matches(">=") + .trim_start_matches("<=") + .trim_start_matches("!=") + .trim_start_matches('>') + .trim_start_matches('<') + .trim_start_matches('='); + if after_op.contains(' ') || after_op.contains(',') { + return None; + } + + // Caret: ^X.Y.Z + if let Some(rest) = constraint.strip_prefix('^') { + return bump_caret(rest.trim(), installed_version, stability_flag); + } + + // Tilde: ~X.Y.Z + if let Some(rest) = constraint.strip_prefix('~') { + return bump_tilde(rest.trim(), installed_version, stability_flag); + } + + // Wildcard: * or X.* + if constraint == "*" || constraint.ends_with(".*") { + return bump_wildcard(constraint, installed_version, stability_flag); + } + + // Greater-or-equal: >=X.Y + if let Some(rest) = constraint.strip_prefix(">=") { + return bump_gte(rest.trim(), installed_version, stability_flag); + } + + // Other operators (exact, <, <=, >, !=, range) — leave unchanged + None +} + +// ─── Caret bump ─────────────────────────────────────────────────────────────── + +/// `^X.Y.Z` → bump to installed version if it is greater. +/// +/// The caret prefix is preserved; segments from installed version replace +/// those in the constraint (trimming trailing zeros appropriately). +fn bump_caret(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option { + let constraint_segments = parse_version_segments(rest); + let installed_segments = parse_version_segments(installed_version); + + // The constraint length determines how many segments to compare/output + let n_constraint = constraint_segments.len().max(1); + + // Compare: if installed <= current lower bound, no change needed + // We compare as many segments as the installed version has + let current_lower: Vec = constraint_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + // Build new constraint segments: use installed version, but only up to + // the number of non-trivial segments needed. + // We output at least as many segments as the original constraint had, + // but trim trailing zeros. + let mut new_segs: Vec = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(n_constraint.max(installed_segments.len())) + .collect(); + + // Trim trailing zeros (but keep at least n_constraint segments, minimum 1) + while new_segs.len() > n_constraint && new_segs.last() == Some(&0) { + new_segs.pop(); + } + // Also trim trailing zeros beyond 1 segment + while new_segs.len() > 1 && new_segs.last() == Some(&0) { + new_segs.pop(); + } + + let version_str = new_segs + .iter() + .map(|n| n.to_string()) + .collect::>() + .join("."); + + let new_constraint = format!("^{version_str}"); + let result = append_stability_flag(&new_constraint, stability_flag); + Some(result) +} + +// ─── Tilde bump ─────────────────────────────────────────────────────────────── + +/// `~X.Y.Z` (3 segments) → bump patch: `~X.Y.new_patch` +/// `~X.Y` (2 segments) → convert to caret: `^X.Y.new_patch` +fn bump_tilde(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option { + let constraint_segments = parse_version_segments(rest); + let installed_segments = parse_version_segments(installed_version); + + let current_lower: Vec = constraint_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + let major = installed_segments.first().copied().unwrap_or(0); + let minor = installed_segments.get(1).copied().unwrap_or(0); + let patch = installed_segments.get(2).copied().unwrap_or(0); + + let new_constraint = if constraint_segments.len() >= 3 { + // ~X.Y.Z → keep tilde, bump patch + if patch == 0 { + format!("~{major}.{minor}.0") + } else { + format!("~{major}.{minor}.{patch}") + } + } else { + // ~X.Y → convert to caret + if patch == 0 { + format!("^{major}.{minor}") + } else { + format!("^{major}.{minor}.{patch}") + } + }; + + let result = append_stability_flag(&new_constraint, stability_flag); + Some(result) +} + +// ─── Wildcard bump ──────────────────────────────────────────────────────────── + +/// `*` → `>=installed` +/// `X.*` → `>=installed` (trimming trailing zeros) +fn bump_wildcard( + constraint: &str, + installed_version: &str, + stability_flag: Option<&str>, +) -> Option { + let installed_segments = parse_version_segments(installed_version); + + // Trim trailing zeros + let mut segs = installed_segments.clone(); + while segs.len() > 1 && segs.last() == Some(&0) { + segs.pop(); + } + + let version_str = segs + .iter() + .map(|n| n.to_string()) + .collect::>() + .join("."); + + // For plain wildcard "*", always produce >=installed + if constraint == "*" { + let new_constraint = format!(">={version_str}"); + return Some(append_stability_flag(&new_constraint, stability_flag)); + } + + // For "X.*", if installed is at that major, produce >=installed + let base = constraint.trim_end_matches(".*"); + let base_segs = parse_version_segments(base); + let current_lower: Vec = base_segs + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + let new_constraint = format!(">={version_str}"); + Some(append_stability_flag(&new_constraint, stability_flag)) +} + +// ─── GTE bump ───────────────────────────────────────────────────────────────── + +/// `>=X.Y` → raise to installed version (trimming trailing zeros) +fn bump_gte(rest: &str, installed_version: &str, stability_flag: Option<&str>) -> Option { + let constraint_segments = parse_version_segments(rest); + let installed_segments = parse_version_segments(installed_version); + + let current_lower: Vec = constraint_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + let installed: Vec = installed_segments + .iter() + .copied() + .chain(std::iter::repeat(0)) + .take(4) + .collect(); + + if installed <= current_lower { + return None; + } + + // Trim trailing zeros from installed version + let mut segs = installed_segments.clone(); + while segs.len() > 1 && segs.last() == Some(&0) { + segs.pop(); + } + + let version_str = segs + .iter() + .map(|n| n.to_string()) + .collect::>() + .join("."); + + let new_constraint = format!(">={version_str}"); + let result = append_stability_flag(&new_constraint, stability_flag); + Some(result) +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/// Strip a trailing `@stability` flag from a constraint string. +/// Returns (body, flag) where flag is the `@...` suffix (without the `@`). +fn strip_stability_flag(constraint: &str) -> (&str, Option<&str>) { + let known = ["@dev", "@alpha", "@beta", "@RC", "@rc", "@stable"]; + for flag in &known { + if let Some(body) = constraint.strip_suffix(flag) { + let flag_str = &constraint[body.len()..]; + return (body.trim_end(), Some(flag_str)); + } + } + (constraint, None) +} + +/// Append an optional stability flag to a constraint string. +fn append_stability_flag(constraint: &str, flag: Option<&str>) -> String { + match flag { + Some(f) => format!("{constraint}{f}"), + None => constraint.to_string(), + } +} + +/// Parse a version string into numeric segments. +/// Handles "1.2.3", "1.2", "1", etc. +/// Stops at any non-numeric/non-dot character. +fn parse_version_segments(version: &str) -> Vec { + // Strip inline alias: "dev-master as 1.0.0" → "1.0.0" + let version = if let Some(pos) = version.find(" as ") { + &version[pos + 4..] + } else { + version + }; + + // Strip leading v/V + let version = version + .strip_prefix('v') + .or_else(|| version.strip_prefix('V')) + .unwrap_or(version); + + // Take up to any pre-release suffix (first '-' or '+') + let version = version.split(['-', '+']).next().unwrap_or(version); + + version + .split('.') + .filter_map(|s| s.parse::().ok()) + .collect() +} + +/// Parse the major version number from a version string. +fn parse_major(version: &str) -> Option { + parse_version_segments(version).into_iter().next() +} + +/// Determine the major version that a single disjunct constraint covers. +/// For `^1.2`, returns `Some(1)`. For `^0.3`, returns `Some(0)`. +fn constraint_major(constraint: &str) -> Option { + if let Some(rest) = constraint.strip_prefix('^') { + return parse_version_segments(rest).into_iter().next(); + } + if let Some(rest) = constraint.strip_prefix('~') { + return parse_version_segments(rest).into_iter().next(); + } + if let Some(rest) = constraint.strip_prefix(">=") { + return parse_version_segments(rest).into_iter().next(); + } + // Try as plain version + parse_version_segments(constraint).into_iter().next() +} + +/// Resolve the installed version string to use for comparison. +/// Handles inline aliases (e.g., "dev-main as 2.1.0" → "2.1.0"). +fn resolve_installed_version<'a>( + pretty_version: &'a str, + _version_normalized: Option<&'a str>, +) -> String { + // If pretty_version contains an inline alias, use the alias target + if let Some(pos) = pretty_version.find(" as ") { + return pretty_version[pos + 4..].trim().to_string(); + } + + // If version_normalized is available and not a dev branch, prefer it + // for more precise comparison, but use pretty_version for output + // Actually we use pretty_version for building constraint strings + // since normalized versions have extra .0 suffixes + + // Use pretty_version as-is (strip leading 'v' for normalization) + pretty_version + .strip_prefix('v') + .unwrap_or(pretty_version) + .to_string() +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Caret bumps ─────────────────────────────────────────────────────────── + + #[test] + fn test_caret_bump_basic() { + // ^1.0 + 1.2.1 → ^1.2.1 + let result = bump_requirement("^1.0", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, Some("^1.2.1".to_string())); + } + + #[test] + fn test_caret_no_change_at_lower_bound() { + // ^1.2 + 1.2.0 → None (already at lower bound) + let result = bump_requirement("^1.2", "1.2.0", Some("1.2.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_caret_no_change_exact_match() { + // ^1.2.1 + 1.2.1 → None + let result = bump_requirement("^1.2.1", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, None); + } + + #[test] + fn test_caret_bump_zero_major() { + // ^0.3 + 0.3.5 → ^0.3.5 + let result = bump_requirement("^0.3", "0.3.5", Some("0.3.5.0")); + assert_eq!(result, Some("^0.3.5".to_string())); + } + + #[test] + fn test_caret_bump_three_segments() { + // ^1.0.0 + 1.2.1 → ^1.2.1 + let result = bump_requirement("^1.0.0", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, Some("^1.2.1".to_string())); + } + + #[test] + fn test_caret_bump_minor_only() { + // ^1.2 + 1.5.0 → ^1.5 (trailing zero trimmed) + let result = bump_requirement("^1.2", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, Some("^1.5".to_string())); + } + + // ── Tilde bumps ─────────────────────────────────────────────────────────── + + #[test] + fn test_tilde_three_segment_bump() { + // ~2.0.0 + 2.0.3 → ~2.0.3 + let result = bump_requirement("~2.0.0", "2.0.3", Some("2.0.3.0")); + assert_eq!(result, Some("~2.0.3".to_string())); + } + + #[test] + fn test_tilde_two_segment_becomes_caret() { + // ~2.0 + 2.0.3 → ^2.0.3 + let result = bump_requirement("~2.0", "2.0.3", Some("2.0.3.0")); + assert_eq!(result, Some("^2.0.3".to_string())); + } + + #[test] + fn test_tilde_no_change() { + // ~2.0.3 + 2.0.3 → None + let result = bump_requirement("~2.0.3", "2.0.3", Some("2.0.3.0")); + assert_eq!(result, None); + } + + #[test] + fn test_tilde_two_segment_no_patch() { + // ~2.3 + 2.5.0 → ^2.5 (patch is 0, trimmed) + let result = bump_requirement("~2.3", "2.5.0", Some("2.5.0.0")); + assert_eq!(result, Some("^2.5".to_string())); + } + + // ── Wildcard bumps ──────────────────────────────────────────────────────── + + #[test] + fn test_wildcard_star() { + // * + 1.2.3 → >=1.2.3 + let result = bump_requirement("*", "1.2.3", Some("1.2.3.0")); + assert_eq!(result, Some(">=1.2.3".to_string())); + } + + #[test] + fn test_wildcard_major_star() { + // 2.* + 2.5.0 → >=2.5 + let result = bump_requirement("2.*", "2.5.0", Some("2.5.0.0")); + assert_eq!(result, Some(">=2.5".to_string())); + } + + #[test] + fn test_wildcard_no_change() { + // 2.* + 2.0.0 → None (installed is at lower bound) + let result = bump_requirement("2.*", "2.0.0", Some("2.0.0.0")); + assert_eq!(result, None); + } + + // ── GTE bumps ───────────────────────────────────────────────────────────── + + #[test] + fn test_gte_bump() { + // >=1.2 + 1.5.0 → >=1.5 + let result = bump_requirement(">=1.2", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, Some(">=1.5".to_string())); + } + + #[test] + fn test_gte_no_change() { + // >=1.5 + 1.5.0 → None + let result = bump_requirement(">=1.5", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_gte_with_patch() { + // >=1.2.0 + 1.5.3 → >=1.5.3 + let result = bump_requirement(">=1.2.0", "1.5.3", Some("1.5.3.0")); + assert_eq!(result, Some(">=1.5.3".to_string())); + } + + // ── OR constraints ──────────────────────────────────────────────────────── + + #[test] + fn test_or_constraint_bumps_matching_major() { + // ^1.2 || ^2.3 + 1.3.0 → ^1.3 || ^2.3 + let result = bump_requirement("^1.2 || ^2.3", "1.3.0", Some("1.3.0.0")); + assert_eq!(result, Some("^1.3 || ^2.3".to_string())); + } + + #[test] + fn test_or_constraint_bumps_second_major() { + // ^1.2 || ^2.3 + 2.5.0 → ^1.2 || ^2.5 + let result = bump_requirement("^1.2 || ^2.3", "2.5.0", Some("2.5.0.0")); + assert_eq!(result, Some("^1.2 || ^2.5".to_string())); + } + + #[test] + fn test_or_constraint_no_change() { + // ^1.2 || ^2.3 + 1.2.0 → None + let result = bump_requirement("^1.2 || ^2.3", "1.2.0", Some("1.2.0.0")); + assert_eq!(result, None); + } + + // ── Dev constraints ─────────────────────────────────────────────────────── + + #[test] + fn test_dev_constraint_unchanged() { + // dev-master → None + let result = bump_requirement("dev-master", "dev-master", None); + assert_eq!(result, None); + } + + #[test] + fn test_dev_installed_no_alias_unchanged() { + // Installed is dev-main without alias → None + let result = bump_requirement("^1.0", "dev-main", None); + assert_eq!(result, None); + } + + #[test] + fn test_dev_installed_with_alias() { + // Installed is "dev-main as 1.2.0" → bump based on alias + let result = bump_requirement("^1.0", "dev-main as 1.2.0", None); + assert_eq!(result, Some("^1.2".to_string())); + } + + // ── Stability flags ─────────────────────────────────────────────────────── + + #[test] + fn test_stability_flag_preserved() { + // ^1.0@dev + 1.2.0 → ^1.2@dev + let result = bump_requirement("^1.0@dev", "1.2.0", Some("1.2.0.0")); + assert_eq!(result, Some("^1.2@dev".to_string())); + } + + #[test] + fn test_stability_flag_beta_preserved() { + // ^1.0@beta + 1.2.1 → ^1.2.1@beta + let result = bump_requirement("^1.0@beta", "1.2.1", Some("1.2.1.0")); + assert_eq!(result, Some("^1.2.1@beta".to_string())); + } + + // ── Edge cases ──────────────────────────────────────────────────────────── + + #[test] + fn test_exact_constraint_no_bump() { + // 1.2.3 → None (exact version, not bumped) + let result = bump_requirement("1.2.3", "1.3.0", Some("1.3.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_complex_range_no_bump() { + // >=1.0 <2.0 → None (complex range, not bumped) + let result = bump_requirement(">=1.0 <2.0", "1.5.0", Some("1.5.0.0")); + assert_eq!(result, None); + } + + #[test] + fn test_parse_version_segments_basic() { + assert_eq!(parse_version_segments("1.2.3"), vec![1, 2, 3]); + assert_eq!(parse_version_segments("1.2"), vec![1, 2]); + assert_eq!(parse_version_segments("1"), vec![1]); + } + + #[test] + fn test_parse_version_segments_with_prerelease() { + assert_eq!(parse_version_segments("1.2.3-beta1"), vec![1, 2, 3]); + } + + #[test] + fn test_parse_version_segments_with_v_prefix() { + assert_eq!(parse_version_segments("v1.2.3"), vec![1, 2, 3]); + } + + #[test] + fn test_parse_version_segments_alias() { + // "dev-master as 1.0.0" → segments of "1.0.0" + assert_eq!(parse_version_segments("dev-master as 1.0.0"), vec![1, 0, 0]); + } +} -- cgit v1.3.1