aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 10:09:58 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 10:09:58 +0900
commit0b5d333083f1317391338d3aa67b1290e93922cc (patch)
tree3842907fc1b2ac64c925b477b4daf33f529a5869 /crates
parent999fc4157cf631f967a5adedeccb83ae6d0cb0f8 (diff)
downloadphp-mozart-0b5d333083f1317391338d3aa67b1290e93922cc.tar.gz
php-mozart-0b5d333083f1317391338d3aa67b1290e93922cc.tar.zst
php-mozart-0b5d333083f1317391338d3aa67b1290e93922cc.zip
feat(require): implement require command with Packagist version resolution
Add the require command that updates composer.json with new package dependencies. When no version constraint is specified, the best version is resolved from the Packagist p2 API based on minimum-stability. Includes packagist API client, version comparison/stability detection, and RawPackageData deserialization support for roundtrip editing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart/Cargo.toml2
-rw-r--r--crates/mozart/src/commands/init.rs17
-rw-r--r--crates/mozart/src/commands/require.rs152
-rw-r--r--crates/mozart/src/console.rs2
-rw-r--r--crates/mozart/src/lib.rs2
-rw-r--r--crates/mozart/src/package.rs92
-rw-r--r--crates/mozart/src/packagist.rs141
-rw-r--r--crates/mozart/src/version.rs287
8 files changed, 673 insertions, 22 deletions
diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml
index e54249f..f4d160b 100644
--- a/crates/mozart/Cargo.toml
+++ b/crates/mozart/Cargo.toml
@@ -9,7 +9,7 @@ clap = { version = "4.5.57", features = ["derive"] }
colored = "3.1.1"
dialoguer = "0.12.0"
regex = "1.12.3"
-reqwest = "0.13.2"
+reqwest = { version = "0.13.2", features = ["blocking", "json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.49.0", features = ["full"] }
diff --git a/crates/mozart/src/commands/init.rs b/crates/mozart/src/commands/init.rs
index 64a0263..fb7520f 100644
--- a/crates/mozart/src/commands/init.rs
+++ b/crates/mozart/src/commands/init.rs
@@ -1,14 +1,12 @@
-use std::collections::BTreeMap;
-use std::path::{Path, PathBuf};
-use std::process::Command;
+use crate::console;
+use crate::package::{self, RawAuthor, RawAutoload, RawPackageData, RawRepository};
+use crate::validation;
use anyhow::{Context, bail};
use clap::Args;
use colored::Colorize;
-use crate::console;
-use crate::package::{
- self, RawAuthor, RawAutoload, RawPackageData, RawRepository,
-};
-use crate::validation;
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+use std::process::Command;
#[derive(Args)]
pub struct InitArgs {
@@ -103,8 +101,7 @@ pub fn execute(args: &InitArgs, cli: &super::Cli) -> anyhow::Result<()> {
console.info(&format!("Writing {}", composer_file.display()));
}
- package::write_to_file(&composer, &composer_file)
- .context("Failed to write composer.json")?;
+ package::write_to_file(&composer, &composer_file).context("Failed to write composer.json")?;
// Create autoload directory if specified
if let Some(ref autoload) = composer.autoload {
diff --git a/crates/mozart/src/commands/require.rs b/crates/mozart/src/commands/require.rs
index 923a77c..603e6e8 100644
--- a/crates/mozart/src/commands/require.rs
+++ b/crates/mozart/src/commands/require.rs
@@ -1,3 +1,8 @@
+use crate::console;
+use crate::package::{self, Stability};
+use crate::packagist;
+use crate::validation;
+use crate::version;
use clap::Args;
#[derive(Args)]
@@ -118,6 +123,149 @@ pub struct RequireArgs {
pub apcu_autoloader_prefix: Option<String>,
}
-pub fn execute(_args: &RequireArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+pub fn execute(args: &RequireArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ if args.packages.is_empty() {
+ anyhow::bail!("Not enough arguments (missing: \"packages\").");
+ }
+
+ // Resolve working directory
+ let working_dir = if let Some(ref dir) = cli.working_dir {
+ std::path::PathBuf::from(dir)
+ } else {
+ std::env::current_dir()?
+ };
+
+ let composer_path = working_dir.join("composer.json");
+ if !composer_path.exists() {
+ anyhow::bail!(
+ "composer.json not found in {}. Run `mozart init` to create one.",
+ working_dir.display()
+ );
+ }
+
+ // Read existing composer.json
+ let mut raw = package::read_from_file(&composer_path)?;
+
+ // Determine preferred stability from composer.json's minimum-stability
+ let preferred_stability = raw
+ .minimum_stability
+ .as_deref()
+ .map(|s| match s.to_lowercase().as_str() {
+ "dev" => Stability::Dev,
+ "alpha" => Stability::Alpha,
+ "beta" => Stability::Beta,
+ "rc" | "RC" => Stability::RC,
+ _ => Stability::Stable,
+ })
+ .unwrap_or(Stability::Stable);
+
+ // Process each package argument
+ let mut additions: Vec<(String, String, bool)> = Vec::new(); // (name, constraint, is_dev)
+
+ for pkg_arg in &args.packages {
+ // Try to parse as "vendor/package:constraint"
+ let (name, constraint) = match validation::parse_require_string(pkg_arg) {
+ Ok((n, v)) => (n.to_lowercase(), v),
+ Err(_) => {
+ // No version specified — resolve from Packagist
+ let name = pkg_arg.trim().to_lowercase();
+ if !validation::validate_package_name(&name) {
+ anyhow::bail!("Invalid package name: \"{name}\"");
+ }
+
+ println!(
+ "{}",
+ console::info(&format!(
+ "Using version constraint for {name} from Packagist..."
+ ))
+ );
+
+ let versions = packagist::fetch_package_versions(&name)?;
+ let best = version::find_best_candidate(&versions, preferred_stability)
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "Could not find a version of package \"{name}\" matching your minimum-stability ({preferred_stability:?}). \
+ Try requiring it with an explicit version constraint."
+ )
+ })?;
+
+ let stability = version::stability_of(&best.version_normalized);
+ let constraint = if args.fixed {
+ best.version.clone()
+ } else {
+ version::find_recommended_require_version(
+ &best.version,
+ &best.version_normalized,
+ stability,
+ )
+ };
+
+ println!(
+ "{}",
+ console::info(&format!("Using version {constraint} for {name}"))
+ );
+
+ (name, constraint)
+ }
+ };
+
+ additions.push((name, constraint, args.dev));
+ }
+
+ // Apply changes
+ for (name, constraint, is_dev) in &additions {
+ let section_name = if *is_dev { "require-dev" } else { "require" };
+ let target = if *is_dev {
+ &mut raw.require_dev
+ } else {
+ &mut raw.require
+ };
+
+ if let Some(existing) = target.get(name) {
+ println!(
+ "{}",
+ console::comment(&format!(
+ "Updating {name} from {existing} to {constraint} in {section_name}"
+ ))
+ );
+ } else {
+ println!(
+ "{}",
+ console::info(&format!("Adding {name} ({constraint}) to {section_name}"))
+ );
+ }
+
+ target.insert(name.clone(), constraint.clone());
+ }
+
+ // Sort packages if requested
+ if args.sort_packages {
+ let sorted_require: std::collections::BTreeMap<_, _> = raw.require.clone();
+ raw.require = sorted_require;
+ let sorted_dev: std::collections::BTreeMap<_, _> = raw.require_dev.clone();
+ raw.require_dev = sorted_dev;
+ }
+
+ // Write back
+ if args.dry_run {
+ println!(
+ "{}",
+ console::comment("Dry run: composer.json not modified.")
+ );
+ } else {
+ package::write_to_file(&raw, &composer_path)?;
+ }
+
+ // Dependency resolution / install notice
+ if !args.no_update && !args.no_install {
+ println!(
+ "{}",
+ console::comment(
+ "Dependency resolution and installation are not yet implemented. \
+ The composer.json has been updated."
+ )
+ );
+ }
+
+ Ok(())
}
diff --git a/crates/mozart/src/console.rs b/crates/mozart/src/console.rs
index 07eaf67..863e9ba 100644
--- a/crates/mozart/src/console.rs
+++ b/crates/mozart/src/console.rs
@@ -51,7 +51,7 @@ impl Console {
}
pub fn error(&self, msg: &str) {
- eprintln!("{}", console::error(msg));
+ eprintln!("{}", error(msg));
}
pub fn ask(&self, prompt: &str, default: &str) -> String {
diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs
index 6506d11..5e5487a 100644
--- a/crates/mozart/src/lib.rs
+++ b/crates/mozart/src/lib.rs
@@ -1,4 +1,6 @@
pub mod commands;
pub mod console;
pub mod package;
+pub mod packagist;
pub mod validation;
+pub mod version;
diff --git a/crates/mozart/src/package.rs b/crates/mozart/src/package.rs
index 1823918..e0e8c6c 100644
--- a/crates/mozart/src/package.rs
+++ b/crates/mozart/src/package.rs
@@ -1,4 +1,4 @@
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
@@ -440,7 +440,7 @@ delegate_complete_package!(RootPackageData => complete);
/// 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)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawPackageData {
pub name: String,
@@ -456,25 +456,33 @@ pub struct RawPackageData {
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
- #[serde(skip_serializing_if = "Vec::is_empty")]
+ #[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", skip_serializing_if = "BTreeMap::is_empty")]
+ #[serde(
+ rename = "require-dev",
+ default,
+ skip_serializing_if = "BTreeMap::is_empty"
+ )]
pub require_dev: BTreeMap<String, String>,
- #[serde(skip_serializing_if = "Vec::is_empty")]
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub repositories: Vec<RawRepository>,
#[serde(skip_serializing_if = "Option::is_none")]
pub autoload: Option<RawAutoload>,
+
+ #[serde(flatten)]
+ pub extra_fields: BTreeMap<String, serde_json::Value>,
}
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawAuthor {
pub name: String,
@@ -482,13 +490,13 @@ pub struct RawAuthor {
pub email: Option<String>,
}
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawAutoload {
#[serde(rename = "psr-4")]
pub psr4: BTreeMap<String, String>,
}
-#[derive(Debug, Clone, Serialize)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawRepository {
#[serde(rename = "type")]
pub repo_type: String,
@@ -509,10 +517,17 @@ impl RawPackageData {
require_dev: BTreeMap::new(),
repositories: Vec::new(),
autoload: None,
+ 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();
@@ -590,6 +605,67 @@ mod tests {
}
#[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();
diff --git a/crates/mozart/src/packagist.rs b/crates/mozart/src/packagist.rs
new file mode 100644
index 0000000..912ec60
--- /dev/null
+++ b/crates/mozart/src/packagist.rs
@@ -0,0 +1,141 @@
+use serde::Deserialize;
+use std::collections::BTreeMap;
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct PackagistDist {
+ #[serde(rename = "type")]
+ pub dist_type: String,
+ pub url: String,
+ pub reference: Option<String>,
+ pub shasum: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct PackagistSource {
+ #[serde(rename = "type")]
+ pub source_type: String,
+ pub url: String,
+ pub reference: Option<String>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct PackagistVersion {
+ pub version: String,
+ pub version_normalized: String,
+ #[serde(default)]
+ pub require: BTreeMap<String, String>,
+ pub dist: Option<PackagistDist>,
+ pub source: Option<PackagistSource>,
+}
+
+/// Parse a Packagist p2 API JSON response.
+///
+/// The response format is: `{"packages": {"vendor/package": [...]}}`.
+pub fn parse_p2_response(json: &str, package_name: &str) -> anyhow::Result<Vec<PackagistVersion>> {
+ #[derive(Deserialize)]
+ struct P2Response {
+ packages: BTreeMap<String, Vec<PackagistVersion>>,
+ }
+
+ let response: P2Response = serde_json::from_str(json)?;
+ response
+ .packages
+ .into_iter()
+ .find(|(key, _)| key == package_name)
+ .map(|(_, versions)| versions)
+ .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response"))
+}
+
+/// Fetch package version metadata from the Packagist p2 API.
+pub fn fetch_package_versions(package_name: &str) -> anyhow::Result<Vec<PackagistVersion>> {
+ let url = format!("https://repo.packagist.org/p2/{package_name}.json");
+ let response = reqwest::blocking::get(&url)?;
+
+ if !response.status().is_success() {
+ anyhow::bail!(
+ "Failed to fetch package \"{package_name}\" from Packagist (HTTP {})",
+ response.status()
+ );
+ }
+
+ let body = response.text()?;
+ parse_p2_response(&body, package_name)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_p2_response_basic() {
+ let json = r#"{
+ "packages": {
+ "monolog/monolog": [
+ {
+ "version": "3.8.0",
+ "version_normalized": "3.8.0.0",
+ "require": {"php": ">=8.1"},
+ "dist": {
+ "type": "zip",
+ "url": "https://example.com/monolog-3.8.0.zip",
+ "reference": "abc123",
+ "shasum": ""
+ },
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Seldaek/monolog.git",
+ "reference": "abc123"
+ }
+ },
+ {
+ "version": "3.7.0",
+ "version_normalized": "3.7.0.0",
+ "require": {"php": ">=8.1"}
+ }
+ ]
+ }
+ }"#;
+
+ let versions = parse_p2_response(json, "monolog/monolog").unwrap();
+ assert_eq!(versions.len(), 2);
+ assert_eq!(versions[0].version, "3.8.0");
+ assert_eq!(versions[0].version_normalized, "3.8.0.0");
+ assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1");
+ assert!(versions[0].dist.is_some());
+ assert!(versions[0].source.is_some());
+ assert_eq!(versions[1].version, "3.7.0");
+ assert!(versions[1].dist.is_none());
+ }
+
+ #[test]
+ fn parse_p2_response_not_found() {
+ let json = r#"{"packages": {"other/pkg": []}}"#;
+ let result = parse_p2_response(json, "monolog/monolog");
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn parse_p2_response_with_dev_version() {
+ let json = r#"{
+ "packages": {
+ "test/pkg": [
+ {
+ "version": "dev-master",
+ "version_normalized": "dev-master",
+ "require": {}
+ },
+ {
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "require": {}
+ }
+ ]
+ }
+ }"#;
+
+ let versions = parse_p2_response(json, "test/pkg").unwrap();
+ assert_eq!(versions.len(), 2);
+ assert_eq!(versions[0].version, "dev-master");
+ assert_eq!(versions[1].version, "1.0.0");
+ }
+}
diff --git a/crates/mozart/src/version.rs b/crates/mozart/src/version.rs
new file mode 100644
index 0000000..a5eca13
--- /dev/null
+++ b/crates/mozart/src/version.rs
@@ -0,0 +1,287 @@
+use crate::package::Stability;
+use crate::packagist::PackagistVersion;
+use std::cmp::Ordering;
+
+/// Determine the stability of a normalized version string.
+pub fn stability_of(version_normalized: &str) -> Stability {
+ let v = version_normalized.to_lowercase();
+ if v.starts_with("dev-") || v.ends_with("-dev") {
+ return Stability::Dev;
+ }
+ // Check for pre-release suffixes: alpha, beta, RC
+ // Normalized versions use formats like "1.0.0.0-alpha1", "1.0.0.0-beta2", "1.0.0.0-RC1"
+ if let Some(pos) = v.rfind('-') {
+ let suffix = &v[pos + 1..];
+ if suffix.starts_with("alpha") {
+ return Stability::Alpha;
+ }
+ if suffix.starts_with("beta") {
+ return Stability::Beta;
+ }
+ if suffix.starts_with("rc") || suffix.starts_with("RC") {
+ return Stability::RC;
+ }
+ }
+ Stability::Stable
+}
+
+/// Compare two normalized version strings (e.g. "1.2.3.0" vs "1.2.4.0").
+///
+/// Each version is split into numeric parts. Non-numeric suffixes (like "-beta1")
+/// are handled by treating the base parts as numeric and the suffix separately.
+pub fn compare_normalized_versions(a: &str, b: &str) -> Ordering {
+ let parse = |v: &str| -> (Vec<u64>, Option<String>) {
+ // Split off any pre-release suffix
+ let (base, suffix) = if let Some(pos) = v.find('-') {
+ (&v[..pos], Some(v[pos + 1..].to_string()))
+ } else {
+ (v, None)
+ };
+ let parts: Vec<u64> = base.split('.').filter_map(|p| p.parse().ok()).collect();
+ (parts, suffix)
+ };
+
+ let (a_parts, a_suffix) = parse(a);
+ let (b_parts, b_suffix) = parse(b);
+
+ // Compare numeric parts
+ let max_len = a_parts.len().max(b_parts.len());
+ for i in 0..max_len {
+ let a_val = a_parts.get(i).copied().unwrap_or(0);
+ let b_val = b_parts.get(i).copied().unwrap_or(0);
+ match a_val.cmp(&b_val) {
+ Ordering::Equal => continue,
+ other => return other,
+ }
+ }
+
+ // If numeric parts are equal, compare stability
+ // A stable version (no suffix) is greater than a pre-release
+ match (&a_suffix, &b_suffix) {
+ (None, None) => Ordering::Equal,
+ (None, Some(_)) => Ordering::Greater, // stable > pre-release
+ (Some(_), None) => Ordering::Less, // pre-release < stable
+ (Some(a_s), Some(b_s)) => {
+ let stab_a = stability_of(&format!("0.0.0.0-{a_s}"));
+ let stab_b = stability_of(&format!("0.0.0.0-{b_s}"));
+ // Lower stability value = more stable = greater version
+ match stab_a.cmp(&stab_b) {
+ Ordering::Equal => a_s.cmp(b_s),
+ // Stability enum: Stable(0) < RC(5) < Beta(10) < Alpha(15) < Dev(20)
+ // But more stable = higher version, so we reverse
+ Ordering::Less => Ordering::Greater,
+ Ordering::Greater => Ordering::Less,
+ }
+ }
+ }
+}
+
+/// Find the best version candidate given a preferred minimum stability.
+///
+/// Returns the highest version whose stability is at least as stable as
+/// the preferred stability (i.e., stability value <= preferred value).
+pub fn find_best_candidate(
+ versions: &[PackagistVersion],
+ preferred_stability: Stability,
+) -> Option<&PackagistVersion> {
+ versions
+ .iter()
+ .filter(|v| stability_of(&v.version_normalized) <= preferred_stability)
+ .max_by(|a, b| compare_normalized_versions(&a.version_normalized, &b.version_normalized))
+}
+
+/// Generate a recommended version constraint string from a concrete version.
+///
+/// Examples:
+/// - `"1.2.1"` (stable) → `"^1.2"`
+/// - `"0.3.5"` (stable) → `"^0.3"`
+/// - `"2.0.0-beta.1"` (beta) → `"^2.0@beta"`
+/// - `"dev-master"` (dev) → `"dev-master"`
+pub fn find_recommended_require_version(
+ version: &str,
+ version_normalized: &str,
+ stability: Stability,
+) -> String {
+ // dev branches are returned as-is
+ if stability == Stability::Dev {
+ return version.to_string();
+ }
+
+ // Extract major.minor from the normalized version (e.g. "1.2.3.0" → "1.2")
+ let base = if let Some(pos) = version_normalized.find('-') {
+ &version_normalized[..pos]
+ } else {
+ version_normalized
+ };
+
+ let parts: Vec<&str> = base.split('.').collect();
+ let major = parts.first().copied().unwrap_or("0");
+ let minor = parts.get(1).copied().unwrap_or("0");
+
+ let constraint = format!("^{major}.{minor}");
+
+ match stability {
+ Stability::Stable => constraint,
+ Stability::RC => format!("{constraint}@RC"),
+ Stability::Beta => format!("{constraint}@beta"),
+ Stability::Alpha => format!("{constraint}@alpha"),
+ Stability::Dev => unreachable!(),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_stability_of() {
+ assert_eq!(stability_of("1.0.0.0"), Stability::Stable);
+ assert_eq!(stability_of("2.3.1.0"), Stability::Stable);
+ assert_eq!(stability_of("1.0.0.0-alpha1"), Stability::Alpha);
+ assert_eq!(stability_of("1.0.0.0-beta2"), Stability::Beta);
+ assert_eq!(stability_of("1.0.0.0-RC1"), Stability::RC);
+ assert_eq!(stability_of("dev-master"), Stability::Dev);
+ assert_eq!(stability_of("dev-feature/foo"), Stability::Dev);
+ assert_eq!(stability_of("1.0.0.0-dev"), Stability::Dev);
+ }
+
+ #[test]
+ fn test_compare_normalized_versions() {
+ assert_eq!(
+ compare_normalized_versions("1.0.0.0", "1.0.0.0"),
+ Ordering::Equal
+ );
+ assert_eq!(
+ compare_normalized_versions("2.0.0.0", "1.0.0.0"),
+ Ordering::Greater
+ );
+ assert_eq!(
+ compare_normalized_versions("1.0.0.0", "2.0.0.0"),
+ Ordering::Less
+ );
+ assert_eq!(
+ compare_normalized_versions("1.2.0.0", "1.1.0.0"),
+ Ordering::Greater
+ );
+ assert_eq!(
+ compare_normalized_versions("1.0.0.0", "1.0.0.0-beta1"),
+ Ordering::Greater
+ );
+ assert_eq!(
+ compare_normalized_versions("1.0.0.0-RC1", "1.0.0.0-beta1"),
+ Ordering::Greater
+ );
+ }
+
+ #[test]
+ fn test_find_best_candidate_stable() {
+ let versions = vec![
+ PackagistVersion {
+ version: "dev-master".to_string(),
+ version_normalized: "dev-master".to_string(),
+ require: Default::default(),
+ dist: None,
+ source: None,
+ },
+ PackagistVersion {
+ version: "2.0.0-beta.1".to_string(),
+ version_normalized: "2.0.0.0-beta1".to_string(),
+ require: Default::default(),
+ dist: None,
+ source: None,
+ },
+ PackagistVersion {
+ version: "1.5.0".to_string(),
+ version_normalized: "1.5.0.0".to_string(),
+ require: Default::default(),
+ dist: None,
+ source: None,
+ },
+ PackagistVersion {
+ version: "1.4.0".to_string(),
+ version_normalized: "1.4.0.0".to_string(),
+ require: Default::default(),
+ dist: None,
+ source: None,
+ },
+ ];
+
+ let best = find_best_candidate(&versions, Stability::Stable).unwrap();
+ assert_eq!(best.version, "1.5.0");
+ }
+
+ #[test]
+ fn test_find_best_candidate_beta() {
+ let versions = vec![
+ PackagistVersion {
+ version: "dev-master".to_string(),
+ version_normalized: "dev-master".to_string(),
+ require: Default::default(),
+ dist: None,
+ source: None,
+ },
+ PackagistVersion {
+ version: "2.0.0-beta.1".to_string(),
+ version_normalized: "2.0.0.0-beta1".to_string(),
+ require: Default::default(),
+ dist: None,
+ source: None,
+ },
+ PackagistVersion {
+ version: "1.5.0".to_string(),
+ version_normalized: "1.5.0.0".to_string(),
+ require: Default::default(),
+ dist: None,
+ source: None,
+ },
+ ];
+
+ let best = find_best_candidate(&versions, Stability::Beta).unwrap();
+ assert_eq!(best.version, "2.0.0-beta.1");
+ }
+
+ #[test]
+ fn test_find_best_candidate_no_match() {
+ let versions = vec![PackagistVersion {
+ version: "dev-master".to_string(),
+ version_normalized: "dev-master".to_string(),
+ require: Default::default(),
+ dist: None,
+ source: None,
+ }];
+
+ let best = find_best_candidate(&versions, Stability::Stable);
+ assert!(best.is_none());
+ }
+
+ #[test]
+ fn test_find_recommended_require_version() {
+ // Stable
+ assert_eq!(
+ find_recommended_require_version("1.2.1", "1.2.1.0", Stability::Stable),
+ "^1.2"
+ );
+ assert_eq!(
+ find_recommended_require_version("0.3.5", "0.3.5.0", Stability::Stable),
+ "^0.3"
+ );
+
+ // Beta
+ assert_eq!(
+ find_recommended_require_version("2.0.0-beta.1", "2.0.0.0-beta1", Stability::Beta),
+ "^2.0@beta"
+ );
+
+ // RC
+ assert_eq!(
+ find_recommended_require_version("3.0.0-RC1", "3.0.0.0-RC1", Stability::RC),
+ "^3.0@RC"
+ );
+
+ // Dev
+ assert_eq!(
+ find_recommended_require_version("dev-master", "dev-master", Stability::Dev),
+ "dev-master"
+ );
+ }
+}