aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-22 00:37:54 +0900
committernsfisis <nsfisis@gmail.com>2026-02-22 00:37:54 +0900
commit0a8e5935e6305819bb02d8c69e2f046ff397913a (patch)
treee5a288e679477b1603d7989e986ca22bbe590aa4 /crates/mozart-core/src
parentb5af594fec7da72b15c9a202c641af0494db6355 (diff)
downloadphp-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.tar.gz
php-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.tar.zst
php-mozart-0a8e5935e6305819bb02d8c69e2f046ff397913a.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-core/src')
-rw-r--r--crates/mozart-core/src/console.rs358
-rw-r--r--crates/mozart-core/src/exit_code.rs114
-rw-r--r--crates/mozart-core/src/lib.rs7
-rw-r--r--crates/mozart-core/src/package.rs703
-rw-r--r--crates/mozart-core/src/platform.rs351
-rw-r--r--crates/mozart-core/src/suggest.rs220
-rw-r--r--crates/mozart-core/src/validation.rs226
-rw-r--r--crates/mozart-core/src/version_bumper.rs667
8 files changed, 2646 insertions, 0 deletions
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)
+// ---------------------------------------------------------------------------
+
+/// `<info>` — green foreground
+pub fn info(message: &str) -> ColoredString {
+ message.green()
+}
+
+/// `<comment>` — yellow foreground
+pub fn comment(message: &str) -> ColoredString {
+ message.yellow()
+}
+
+/// `<error>` — white on red
+pub fn error(message: &str) -> ColoredString {
+ message.white().on_red()
+}
+
+/// `<question>` — black on cyan
+pub fn question(message: &str) -> ColoredString {
+ message.black().on_cyan()
+}
+
+/// `<highlight>` — red foreground (Composer extension)
+pub fn highlight(message: &str) -> ColoredString {
+ message.red()
+}
+
+/// `<warning>` — 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<F>(
+ &self,
+ prompt: &str,
+ default: &str,
+ validator: F,
+ ) -> Result<String, String>
+ 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<String>) -> 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::<MozartError>().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::<MozartError>().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<dyn std::error::Error> = 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<String>,
+ pub description: String,
+}
+
+/// Package author metadata.
+#[derive(Debug, Clone)]
+pub struct Author {
+ pub name: Option<String>,
+ pub email: Option<String>,
+ pub homepage: Option<String>,
+ pub role: Option<String>,
+}
+
+/// Autoload rule sets (PSR-4, PSR-0, classmap, files).
+#[derive(Debug, Clone, Default)]
+pub struct AutoloadRules {
+ pub psr4: BTreeMap<String, Vec<String>>,
+ pub psr0: BTreeMap<String, Vec<String>>,
+ pub classmap: Vec<String>,
+ pub files: Vec<String>,
+}
+
+/// Support channel information.
+#[derive(Debug, Clone, Default)]
+pub struct Support {
+ pub email: Option<String>,
+ pub issues: Option<String>,
+ pub forum: Option<String>,
+ pub wiki: Option<String>,
+ pub source: Option<String>,
+ pub docs: Option<String>,
+ pub irc: Option<String>,
+ pub chat: Option<String>,
+ pub rss: Option<String>,
+ pub security: Option<String>,
+}
+
+/// Funding link.
+#[derive(Debug, Clone)]
+pub struct Funding {
+ pub url: Option<String>,
+ pub funding_type: Option<String>,
+}
+
+/// 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<String>,
+
+ // source
+ pub source_type: Option<String>,
+ pub source_url: Option<String>,
+ pub source_reference: Option<String>,
+
+ // dist
+ pub dist_type: Option<String>,
+ pub dist_url: Option<String>,
+ pub dist_reference: Option<String>,
+ pub dist_sha1_checksum: Option<String>,
+
+ pub release_date: Option<String>,
+ pub extra: BTreeMap<String, serde_json::Value>,
+ pub binaries: Vec<String>,
+ pub dev: bool,
+ pub stability: Stability,
+ pub notification_url: Option<String>,
+
+ // dependency links
+ pub requires: BTreeMap<String, Link>,
+ pub conflicts: BTreeMap<String, Link>,
+ pub provides: BTreeMap<String, Link>,
+ pub replaces: BTreeMap<String, Link>,
+ pub dev_requires: BTreeMap<String, Link>,
+ pub suggests: BTreeMap<String, String>,
+
+ // 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<String>,
+ pub homepage: Option<String>,
+ pub license: Vec<String>,
+ pub keywords: Vec<String>,
+ pub authors: Vec<Author>,
+ pub scripts: BTreeMap<String, Vec<String>>,
+ pub support: Support,
+ pub funding: Vec<Funding>,
+ pub repositories: Vec<serde_json::Value>,
+ /// `None` = not abandoned, `Some("")` = abandoned, `Some(pkg)` = replaced by pkg.
+ pub abandoned: Option<String>,
+ pub archive_name: Option<String>,
+ pub archive_excludes: Vec<String>,
+}
+
+/// 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<String, Stability>,
+ pub config: BTreeMap<String, serde_json::Value>,
+ pub references: BTreeMap<String, String>,
+ pub aliases: Vec<VersionAlias>,
+}
+
+/// 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<String, serde_json::Value>;
+ fn binaries(&self) -> &[String];
+ fn is_dev(&self) -> bool;
+ fn stability(&self) -> Stability;
+ fn notification_url(&self) -> Option<&str>;
+ fn requires(&self) -> &BTreeMap<String, Link>;
+ fn conflicts(&self) -> &BTreeMap<String, Link>;
+ fn provides(&self) -> &BTreeMap<String, Link>;
+ fn replaces(&self) -> &BTreeMap<String, Link>;
+ fn dev_requires(&self) -> &BTreeMap<String, Link>;
+ fn suggests(&self) -> &BTreeMap<String, String>;
+ 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<String, Vec<String>>;
+ 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<String, Stability>;
+ fn config(&self) -> &BTreeMap<String, serde_json::Value>;
+ fn references(&self) -> &BTreeMap<String, String>;
+ 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<String, serde_json::Value> { &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<String, Link> { &self.$($path).+.requires }
+ fn conflicts(&self) -> &BTreeMap<String, Link> { &self.$($path).+.conflicts }
+ fn provides(&self) -> &BTreeMap<String, Link> { &self.$($path).+.provides }
+ fn replaces(&self) -> &BTreeMap<String, Link> { &self.$($path).+.replaces }
+ fn dev_requires(&self) -> &BTreeMap<String, Link> { &self.$($path).+.dev_requires }
+ fn suggests(&self) -> &BTreeMap<String, String> { &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<String, Vec<String>> { &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<String, serde_json::Value> {
+ &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<String, Link> {
+ &self.requires
+ }
+ fn conflicts(&self) -> &BTreeMap<String, Link> {
+ &self.conflicts
+ }
+ fn provides(&self) -> &BTreeMap<String, Link> {
+ &self.provides
+ }
+ fn replaces(&self) -> &BTreeMap<String, Link> {
+ &self.replaces
+ }
+ fn dev_requires(&self) -> &BTreeMap<String, Link> {
+ &self.dev_requires
+ }
+ fn suggests(&self) -> &BTreeMap<String, String> {
+ &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<String, Vec<String>> {
+ &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<String, Stability> {
+ &self.stability_flags
+ }
+ fn config(&self) -> &BTreeMap<String, serde_json::Value> {
+ &self.config
+ }
+ fn references(&self) -> &BTreeMap<String, String> {
+ &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<String>,
+
+ #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
+ pub package_type: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub homepage: Option<String>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub license: Option<String>,
+
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub authors: Vec<RawAuthor>,
+
+ #[serde(rename = "minimum-stability", skip_serializing_if = "Option::is_none")]
+ pub minimum_stability: Option<String>,
+
+ #[serde(default)]
+ pub require: BTreeMap<String, String>,
+
+ #[serde(
+ rename = "require-dev",
+ default,
+ skip_serializing_if = "BTreeMap::is_empty"
+ )]
+ pub require_dev: BTreeMap<String, String>,
+
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub repositories: Vec<RawRepository>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub autoload: Option<RawAutoload>,
+
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub bin: Vec<String>,
+
+ #[serde(flatten)]
+ pub extra_fields: BTreeMap<String, serde_json::Value>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RawAuthor {
+ pub name: String,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub email: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RawAutoload {
+ #[serde(rename = "psr-4")]
+ pub psr4: BTreeMap<String, String>,
+}
+
+#[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<RawPackageData> {
+ 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<String> {
+ 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<PlatformPackage> {
+ 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<PlatformPackage> {
+ let mut packages: Vec<PlatformPackage> = 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<PlatformPackage> = 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<String> {
+ 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<String> {
+ 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<char> = a.chars().collect();
+ let b: Vec<char> = 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<usize> = (0..=n).collect();
+ let mut curr: Vec<usize> = 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<Item = &'a str>,
+) -> 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<String> {
+ if suggestions.is_empty() {
+ return None;
+ }
+
+ let formatted = suggestions
+ .iter()
+ .map(|s| format!("\"{}\"", s))
+ .collect::<Vec<_>>()
+ .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<Regex> = LazyLock::new(|| {
+ Regex::new(r"^[a-z0-9]([_.\-]?[a-z0-9]+)*/[a-z0-9](([_.]|\-{1,2})?[a-z0-9]+)*$").unwrap()
+});
+
+static AUTHOR_RE: LazyLock<Regex> = LazyLock::new(|| {
+ Regex::new(r"^(?P<name>[- .,\pL\pN\pM''\x{201C}\x{201D}()]+)(?:\s+<(?P<email>.+?)>)?$").unwrap()
+});
+
+static AUTOLOAD_PATH_RE: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"^[^/][A-Za-z0-9\-_/]+/$").unwrap());
+
+static CAMEL_SPLIT_RE: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"(?:([a-z])([A-Z])|([A-Z])([A-Z][a-z]))").unwrap());
+
+static SANITIZE_EDGES_RE: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"^[_.\-]+|[_.\-]+$|[^a-z0-9_.\-]").unwrap());
+
+static SANITIZE_REPEATS_RE: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r"([_.\-]){2,}").unwrap());
+
+static NON_ALNUM_RE: LazyLock<Regex> = 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<String>,
+}
+
+pub fn parse_author(input: &str) -> Result<ParsedAuthor, String> {
+ 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 <john@example.com>"
+ .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<String> {
+ if package_name.is_empty() || !package_name.contains('/') {
+ return None;
+ }
+
+ let parts: Vec<String> = package_name
+ .split('/')
+ .map(|part| {
+ let replaced = NON_ALNUM_RE.replace_all(part, " ");
+ let words: Vec<String> = replaced
+ .split_whitespace()
+ .map(|w| {
+ let mut chars = w.chars();
+ match chars.next() {
+ Some(c) => c.to_uppercase().to_string() + &chars.collect::<String>(),
+ 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 <john@example.com>").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<String> {
+ 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<String> {
+ 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<String> = 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<String> {
+ // 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<String> {
+ 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<u64> = constraint_segments
+ .iter()
+ .copied()
+ .chain(std::iter::repeat(0))
+ .take(4)
+ .collect();
+ let installed: Vec<u64> = 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<u64> = 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::<Vec<_>>()
+ .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<String> {
+ let constraint_segments = parse_version_segments(rest);
+ let installed_segments = parse_version_segments(installed_version);
+
+ let current_lower: Vec<u64> = constraint_segments
+ .iter()
+ .copied()
+ .chain(std::iter::repeat(0))
+ .take(4)
+ .collect();
+ let installed: Vec<u64> = 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<String> {
+ 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::<Vec<_>>()
+ .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<u64> = base_segs
+ .iter()
+ .copied()
+ .chain(std::iter::repeat(0))
+ .take(4)
+ .collect();
+ let installed: Vec<u64> = 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<String> {
+ let constraint_segments = parse_version_segments(rest);
+ let installed_segments = parse_version_segments(installed_version);
+
+ let current_lower: Vec<u64> = constraint_segments
+ .iter()
+ .copied()
+ .chain(std::iter::repeat(0))
+ .take(4)
+ .collect();
+ let installed: Vec<u64> = 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::<Vec<_>>()
+ .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<u64> {
+ // 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::<u64>().ok())
+ .collect()
+}
+
+/// Parse the major version number from a version string.
+fn parse_major(version: &str) -> Option<u64> {
+ 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<u64> {
+ 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]);
+ }
+}