From b696eb7608d921ae0e14a4296e412c33340ceee8 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 22 Feb 2026 16:37:49 +0900 Subject: refactor: reorganize crates to match Composer subpackage structure Rename mozart-constraint to mozart-semver (mirrors composer/semver) and extract mozart-class-map-generator from mozart-autoload (mirrors composer/class-map-generator). No logic changes. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 17 +- Cargo.toml | 13 +- crates/mozart-autoload/Cargo.toml | 2 +- crates/mozart-autoload/src/autoload.rs | 233 +-- crates/mozart-autoload/src/lib.rs | 1 - crates/mozart-autoload/src/php_scanner.rs | 629 ------- crates/mozart-class-map-generator/Cargo.toml | 11 + crates/mozart-class-map-generator/src/classmap.rs | 239 +++ crates/mozart-class-map-generator/src/lib.rs | 8 + .../mozart-class-map-generator/src/php_scanner.rs | 629 +++++++ crates/mozart-constraint/Cargo.toml | 4 - crates/mozart-constraint/src/lib.rs | 1987 -------------------- crates/mozart-core/Cargo.toml | 2 +- crates/mozart-registry/Cargo.toml | 2 +- crates/mozart-registry/src/resolver.rs | 4 +- crates/mozart-semver/Cargo.toml | 4 + crates/mozart-semver/src/lib.rs | 1987 ++++++++++++++++++++ crates/mozart/Cargo.toml | 4 +- crates/mozart/src/commands/audit.rs | 6 +- crates/mozart/src/commands/check_platform_reqs.rs | 14 +- crates/mozart/src/commands/dependency.rs | 30 +- crates/mozart/src/commands/outdated.rs | 4 +- crates/mozart/src/commands/prohibits.rs | 2 +- 23 files changed, 2933 insertions(+), 2899 deletions(-) delete mode 100644 crates/mozart-autoload/src/php_scanner.rs create mode 100644 crates/mozart-class-map-generator/Cargo.toml create mode 100644 crates/mozart-class-map-generator/src/classmap.rs create mode 100644 crates/mozart-class-map-generator/src/lib.rs create mode 100644 crates/mozart-class-map-generator/src/php_scanner.rs delete mode 100644 crates/mozart-constraint/Cargo.toml delete mode 100644 crates/mozart-constraint/src/lib.rs create mode 100644 crates/mozart-semver/Cargo.toml create mode 100644 crates/mozart-semver/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 663754e..5beeed7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1078,9 +1078,9 @@ dependencies = [ "colored", "mozart-archiver", "mozart-autoload", - "mozart-constraint", "mozart-core", "mozart-registry", + "mozart-semver", "predicates", "regex", "reqwest", @@ -1114,15 +1114,20 @@ version = "0.1.0" dependencies = [ "anyhow", "md5", + "mozart-class-map-generator", "mozart-registry", - "regex", "serde_json", "tempfile", ] [[package]] -name = "mozart-constraint" +name = "mozart-class-map-generator" version = "0.1.0" +dependencies = [ + "anyhow", + "regex", + "tempfile", +] [[package]] name = "mozart-core" @@ -1153,9 +1158,9 @@ dependencies = [ "filetime", "flate2", "md5", - "mozart-constraint", "mozart-core", "mozart-metadata-minifier", + "mozart-semver", "pubgrub", "reqwest", "serde", @@ -1167,6 +1172,10 @@ dependencies = [ "zip", ] +[[package]] +name = "mozart-semver" +version = "0.1.0" + [[package]] name = "mozart-spdx-licenses" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8492d76..3ea91a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,14 +7,16 @@ version = "0.1.0" edition = "2024" [workspace.dependencies] -mozart-constraint = { path = "crates/mozart-constraint" } -mozart-core = { path = "crates/mozart-core" } mozart-archiver = { path = "crates/mozart-archiver" } +mozart-autoload = { path = "crates/mozart-autoload" } +mozart-class-map-generator = { path = "crates/mozart-class-map-generator" } +mozart-core = { path = "crates/mozart-core" } mozart-metadata-minifier = { path = "crates/mozart-metadata-minifier" } -mozart-spdx-licenses = { path = "crates/mozart-spdx-licenses" } mozart-registry = { path = "crates/mozart-registry" } -mozart-autoload = { path = "crates/mozart-autoload" } +mozart-semver = { path = "crates/mozart-semver" } +mozart-spdx-licenses = { path = "crates/mozart-spdx-licenses" } anyhow = "1.0.102" +assert_cmd = "2.1.2" bzip2 = "0.5.2" clap = { version = "4.5.60", features = ["derive"] } clap_complete = "4.5.66" @@ -23,6 +25,7 @@ dialoguer = "0.12.0" filetime = "0.2.27" flate2 = "1.1.9" md5 = "0.7.0" +predicates = "3.1.4" pubgrub = "0.3.0" regex = "1.12.3" reqwest = { version = "0.13.2", features = ["json"] } @@ -36,5 +39,3 @@ tokio = { version = "1.49.0", features = ["full"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter", "fmt"] } zip = { version = "2.4.2", default-features = false, features = ["deflate"] } -assert_cmd = "2.1.2" -predicates = "3.1.4" diff --git a/crates/mozart-autoload/Cargo.toml b/crates/mozart-autoload/Cargo.toml index 93037df..98c4f6f 100644 --- a/crates/mozart-autoload/Cargo.toml +++ b/crates/mozart-autoload/Cargo.toml @@ -4,10 +4,10 @@ version.workspace = true edition.workspace = true [dependencies] +mozart-class-map-generator.workspace = true mozart-registry.workspace = true anyhow.workspace = true md5.workspace = true -regex.workspace = true serde_json.workspace = true [dev-dependencies] diff --git a/crates/mozart-autoload/src/autoload.rs b/crates/mozart-autoload/src/autoload.rs index 2e4158c..27f75ff 100644 --- a/crates/mozart-autoload/src/autoload.rs +++ b/crates/mozart-autoload/src/autoload.rs @@ -1,3 +1,4 @@ +use mozart_class_map_generator::{scan_classmap_dirs, scan_psr_for_classmap}; use mozart_registry::installed::InstalledPackages; use mozart_registry::lockfile::LockedPackage; use std::collections::{BTreeMap, HashSet}; @@ -453,238 +454,6 @@ fn generate_autoload_static(static_data: &AutoloadData, suffix: &str) -> String out } -/// Recursively collect PHP files from a directory, skipping excluded paths. -fn collect_php_files( - dir: &Path, - excluded: &[String], - vendor_dir: &Path, - project_dir: &Path, -) -> Vec { - let mut result = Vec::new(); - if !dir.is_dir() { - return result; - } - collect_php_files_inner(dir, excluded, vendor_dir, project_dir, &mut result); - result -} - -fn collect_php_files_inner( - dir: &Path, - excluded: &[String], - vendor_dir: &Path, - project_dir: &Path, - result: &mut Vec, -) { - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - let path = entry.path(); - - // Check if path matches any excluded pattern - if is_excluded(&path, excluded, vendor_dir, project_dir) { - continue; - } - - if path.is_dir() { - collect_php_files_inner(&path, excluded, vendor_dir, project_dir, result); - } else if crate::php_scanner::is_php_ext(&path) { - result.push(path); - } - } -} - -/// Check whether a path matches any of the excluded patterns. -fn is_excluded(path: &Path, excluded: &[String], vendor_dir: &Path, project_dir: &Path) -> bool { - for exc in excluded { - // Excluded patterns can be relative to project_dir or absolute - let exc_path = if Path::new(exc).is_absolute() { - PathBuf::from(exc) - } else { - project_dir.join(exc) - }; - if path.starts_with(&exc_path) || path == exc_path { - return true; - } - // Also check relative to vendor_dir - let exc_vendor = vendor_dir.join(exc); - if path.starts_with(&exc_vendor) || path == exc_vendor { - return true; - } - } - false -} - -/// Scan directories for PHP class declarations and return a classmap. -/// -/// `dirs` is a list of absolute directory paths to scan. -/// Returns a `BTreeMap` where the path expression -/// uses `$vendorDir` or `$baseDir` as appropriate. -fn scan_classmap_dirs( - dirs: &[PathBuf], - vendor_dir: &Path, - project_dir: &Path, - excluded: &[String], -) -> BTreeMap { - let mut classmap = BTreeMap::new(); - - for dir in dirs { - let files = collect_php_files(dir, excluded, vendor_dir, project_dir); - for file in files { - match crate::php_scanner::find_classes(&file) { - Ok(classes) => { - for class in classes { - let path_expr = path_to_php_expr(&file, vendor_dir, project_dir); - classmap.entry(class).or_insert(path_expr); - } - } - Err(_) => continue, - } - } - } - - classmap -} - -/// Convert an absolute file path to a PHP path expression using `$vendorDir` or `$baseDir`. -fn path_to_php_expr(file: &Path, vendor_dir: &Path, project_dir: &Path) -> String { - if let Ok(rel) = file.strip_prefix(vendor_dir) { - let rel_str = rel.to_string_lossy().replace('\\', "/"); - format!("$vendorDir . '/{rel_str}'") - } else if let Ok(rel) = file.strip_prefix(project_dir) { - let rel_str = rel.to_string_lossy().replace('\\', "/"); - format!("$baseDir . '/{rel_str}'") - } else { - // Fall back to absolute path - let abs = file.to_string_lossy().replace('\\', "/"); - format!("'{abs}'") - } -} - -/// Convert an absolute file path to a static PHP path expression using `__DIR__ . '/..` form. -fn path_to_static_expr(file: &Path, vendor_dir: &Path, project_dir: &Path) -> String { - if let Ok(rel) = file.strip_prefix(vendor_dir) { - let rel_str = rel.to_string_lossy().replace('\\', "/"); - format!("__DIR__ . '/..' . '/{rel_str}'") - } else if let Ok(rel) = file.strip_prefix(project_dir) { - let rel_str = rel.to_string_lossy().replace('\\', "/"); - format!("__DIR__ . '/../..' . '/{rel_str}'") - } else { - let abs = file.to_string_lossy().replace('\\', "/"); - format!("'{abs}'") - } -} - -/// Scan PSR-4 and PSR-0 directories for class declarations (used in optimize mode). -/// -/// Returns `(dynamic_classmap, static_classmap, psr_violations)`. -fn scan_psr_for_classmap( - psr4: &BTreeMap>, - psr0: &BTreeMap>, - vendor_dir: &Path, - project_dir: &Path, - excluded: &[String], -) -> ( - BTreeMap, - BTreeMap, - Vec, -) { - let mut dyn_map: BTreeMap = BTreeMap::new(); - let mut static_map: BTreeMap = BTreeMap::new(); - let mut violations: Vec = Vec::new(); - - // Helper: resolve a PHP path expression to an absolute path. - let resolve = |expr: &str| -> Option { - // Expressions look like: - // $vendorDir . '/psr/log/src' - // $baseDir . '/src' - // __DIR__ . '/..' . '/psr/log/src' - // __DIR__ . '/../..' . '/src' - if let Some(rest) = expr.strip_prefix("$vendorDir . '") { - let rel = rest.trim_end_matches('\''); - Some(vendor_dir.join(rel.trim_start_matches('/'))) - } else if let Some(rest) = expr.strip_prefix("$baseDir . '") { - let rel = rest.trim_end_matches('\''); - Some(project_dir.join(rel.trim_start_matches('/'))) - } else if expr == "$vendorDir" { - Some(vendor_dir.to_path_buf()) - } else if expr == "$baseDir" { - Some(project_dir.to_path_buf()) - } else { - None - } - }; - - // Scan PSR-4 dirs - for (ns, paths) in psr4 { - for path_expr in paths { - if let Some(abs_dir) = resolve(path_expr) { - let files = collect_php_files(&abs_dir, excluded, vendor_dir, project_dir); - for file in files { - match crate::php_scanner::find_classes(&file) { - Ok(classes) => { - for class in classes { - // PSR-4 validation - let file_str = file.to_string_lossy(); - let dir_str = abs_dir.to_string_lossy(); - let base_ns = ns.as_str(); - if !crate::php_scanner::validate_psr4_class( - &class, base_ns, &file_str, &dir_str, - ) { - violations.push(format!( - "Class {class} in {file_str} does not comply with PSR-4 (namespace prefix: {ns})" - )); - } - let dyn_expr = path_to_php_expr(&file, vendor_dir, project_dir); - let static_expr = - path_to_static_expr(&file, vendor_dir, project_dir); - dyn_map.entry(class.clone()).or_insert(dyn_expr); - static_map.entry(class).or_insert(static_expr); - } - } - Err(_) => continue, - } - } - } - } - } - - // Scan PSR-0 dirs - for (ns, paths) in psr0 { - for path_expr in paths { - if let Some(abs_dir) = resolve(path_expr) { - let files = collect_php_files(&abs_dir, excluded, vendor_dir, project_dir); - for file in files { - match crate::php_scanner::find_classes(&file) { - Ok(classes) => { - for class in classes { - let file_str = file.to_string_lossy(); - let dir_str = abs_dir.to_string_lossy(); - if !crate::php_scanner::validate_psr0_class( - &class, &file_str, &dir_str, - ) { - violations.push(format!( - "Class {class} in {file_str} does not comply with PSR-0 (namespace prefix: {ns})" - )); - } - let dyn_expr = path_to_php_expr(&file, vendor_dir, project_dir); - let static_expr = - path_to_static_expr(&file, vendor_dir, project_dir); - dyn_map.entry(class.clone()).or_insert(dyn_expr); - static_map.entry(class).or_insert(static_expr); - } - } - Err(_) => continue, - } - } - } - } - } - - (dyn_map, static_map, violations) -} - /// Generate `vendor/composer/platform_check.php`. /// /// Returns `None` if mode is `Disabled` or there are no relevant requirements. diff --git a/crates/mozart-autoload/src/lib.rs b/crates/mozart-autoload/src/lib.rs index 9d798c6..fc80aed 100644 --- a/crates/mozart-autoload/src/lib.rs +++ b/crates/mozart-autoload/src/lib.rs @@ -1,2 +1 @@ pub mod autoload; -pub mod php_scanner; diff --git a/crates/mozart-autoload/src/php_scanner.rs b/crates/mozart-autoload/src/php_scanner.rs deleted file mode 100644 index 3d0d51d..0000000 --- a/crates/mozart-autoload/src/php_scanner.rs +++ /dev/null @@ -1,629 +0,0 @@ -use anyhow::Result; -use regex::Regex; -use std::path::Path; - -/// File extensions considered PHP source files for class scanning. -const PHP_EXTENSIONS: &[&str] = &["php", "inc", "hh"]; - -/// Check if a file path has a PHP-like extension. -fn is_php_file(path: &Path) -> bool { - is_php_ext(path) -} - -/// Public version of the PHP extension check, used by the autoload scanner. -pub fn is_php_ext(path: &Path) -> bool { - path.extension() - .and_then(|e| e.to_str()) - .map(|ext| PHP_EXTENSIONS.iter().any(|&e| ext.eq_ignore_ascii_case(e))) - .unwrap_or(false) -} - -/// Scan a PHP file and return the list of fully-qualified class names declared in it. -/// -/// Returns an empty vec if the file has no relevant extension or no class declarations. -pub fn find_classes(path: &Path) -> Result> { - if !is_php_file(path) { - return Ok(vec![]); - } - - let contents = std::fs::read_to_string(path)?; - - // Quick check: does the file even contain a class-like keyword? - let quick_re = Regex::new(r"(?i)\b(?:class|interface|trait|enum)\s").unwrap(); - if !quick_re.is_match(&contents) { - return Ok(vec![]); - } - - let cleaned = clean_php_content(&contents); - Ok(extract_declarations(&cleaned)) -} - -/// State machine that strips strings, comments, and heredocs/nowdocs from PHP code. -/// -/// Returns a string of equal byte length where non-PHP content is replaced with spaces -/// so that regex offsets are preserved. Only PHP mode content is kept; everything else -/// is blanked out. -fn clean_php_content(contents: &str) -> String { - let bytes = contents.as_bytes(); - let len = bytes.len(); - let mut out = vec![b' '; len]; - let mut i = 0; - let mut in_php = false; - - while i < len { - if !in_php { - // Look for `` - if i + 1 < len && bytes[i] == b'?' && bytes[i + 1] == b'>' { - in_php = false; - i += 2; - continue; - } - - // Line comment: // or # - if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'/' { - // Skip to end of line - while i < len && bytes[i] != b'\n' { - i += 1; - } - continue; - } - if bytes[i] == b'#' { - while i < len && bytes[i] != b'\n' { - i += 1; - } - continue; - } - - // Block comment: /* ... */ - if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' { - i += 2; - while i + 1 < len { - if bytes[i] == b'*' && bytes[i + 1] == b'/' { - i += 2; - break; - } - i += 1; - } - continue; - } - - // Single-quoted string - if bytes[i] == b'\'' { - out[i] = b'\''; - i += 1; - while i < len { - if bytes[i] == b'\\' && i + 1 < len { - // escaped character — blank both - i += 2; - } else if bytes[i] == b'\'' { - out[i] = b'\''; - i += 1; - break; - } else { - i += 1; - } - } - continue; - } - - // Double-quoted string - if bytes[i] == b'"' { - out[i] = b'"'; - i += 1; - while i < len { - if bytes[i] == b'\\' && i + 1 < len { - i += 2; - } else if bytes[i] == b'"' { - out[i] = b'"'; - i += 1; - break; - } else { - i += 1; - } - } - continue; - } - - // Heredoc / Nowdoc: <<< - if i + 2 < len && bytes[i] == b'<' && bytes[i + 1] == b'<' && bytes[i + 2] == b'<' { - i += 3; - // Skip whitespace - while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') { - i += 1; - } - - // Nowdoc uses single quotes around label; heredoc may use double quotes. - let is_nowdoc = i < len && bytes[i] == b'\''; - // Skip optional opening quote (single for nowdoc, double for heredoc) - if i < len && (bytes[i] == b'\'' || bytes[i] == b'"') { - i += 1; - } - - // Read label - let label_start = i; - while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { - i += 1; - } - let label = std::str::from_utf8(&bytes[label_start..i]) - .unwrap_or("") - .to_string(); - - // Skip closing quote of label (must match the opening quote) - let expected_close = if is_nowdoc { b'\'' } else { b'"' }; - if i < len && bytes[i] == expected_close { - i += 1; - } - - // Skip to end of line - while i < len && bytes[i] != b'\n' { - i += 1; - } - if i < len { - i += 1; // consume newline - } - - // Scan for the terminator label on its own line - if !label.is_empty() { - loop { - if i >= len { - break; - } - // Check if current line starts with the label - let line_start = i; - // Skip optional whitespace for indented heredoc (PHP 7.3+) - while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') { - i += 1; - } - let remaining = &bytes[i..]; - let label_bytes = label.as_bytes(); - if remaining.len() >= label_bytes.len() - && &remaining[..label_bytes.len()] == label_bytes - { - let after = i + label_bytes.len(); - // Terminator must be followed by ; or newline or EOF - if after >= len - || bytes[after] == b';' - || bytes[after] == b'\n' - || bytes[after] == b'\r' - { - // Skip to end of this line - i = after; - while i < len && bytes[i] != b'\n' { - i += 1; - } - if i < len { - i += 1; - } - break; - } - } - // Not a terminator line — skip to end of line - i = line_start; - while i < len && bytes[i] != b'\n' { - i += 1; - } - if i < len { - i += 1; - } - } - } - continue; - } - - // Backtick strings (shell exec) - if bytes[i] == b'`' { - out[i] = b'`'; - i += 1; - while i < len { - if bytes[i] == b'\\' && i + 1 < len { - i += 2; - } else if bytes[i] == b'`' { - out[i] = b'`'; - i += 1; - break; - } else { - i += 1; - } - } - continue; - } - - // Keep normal PHP content - out[i] = bytes[i]; - i += 1; - } - - String::from_utf8_lossy(&out).into_owned() -} - -/// Extract fully-qualified class names from cleaned PHP content. -/// -/// Tracks the current namespace and finds class/interface/trait/enum declarations. -fn extract_declarations(cleaned: &str) -> Vec { - let mut results = Vec::new(); - - // Regex for namespace declarations: - // namespace Foo\Bar; — simple - // namespace Foo\Bar { — block - // namespace { — global block - let ns_re = Regex::new( - r"(?x) - \bnamespace\s+ - ((?:[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*\\)*[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*) - \s*[;{] - | - \bnamespace\s*\{ - ", - ) - .unwrap(); - - // Regex for class/interface/trait/enum declarations. - // We need to capture the name; anonymous classes (new class ...) are excluded. - let decl_re = Regex::new( - r"(?x) - \b(?:abstract\s+|final\s+|readonly\s+)* - (?Pclass|interface|trait|enum)\s+ - (?P[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*) - ", - ) - .unwrap(); - - let mut current_ns = String::new(); - - // We process namespace changes as we walk through the file. - // Build a list of all namespace and declaration positions. - #[derive(Debug)] - enum Event { - Namespace(usize, String), // position, namespace - Declaration(usize, String), // position, simple name - } - - let mut events: Vec = Vec::new(); - - // Find namespace declarations - for cap in ns_re.captures_iter(cleaned) { - let pos = cap.get(0).unwrap().start(); - let ns_name = cap - .get(1) - .map(|m| m.as_str().to_string()) - .unwrap_or_default(); - events.push(Event::Namespace(pos, ns_name)); - } - - // Find class/interface/trait/enum declarations - for cap in decl_re.captures_iter(cleaned) { - let pos = cap.get(0).unwrap().start(); - let name = cap.name("name").unwrap().as_str().to_string(); - - // Skip anonymous classes: check if "new" precedes "class" on the same "expression". - // A reliable check: look back for "new " before this match. - let before = &cleaned[..pos]; - let kind = cap.name("kind").unwrap().as_str(); - if kind == "class" { - // Check if "new" appears right before (with possible whitespace/modifiers). - // Simple heuristic: scan backwards for non-whitespace token. - let trimmed = before.trim_end(); - if trimmed.ends_with("new") { - continue; - } - } - - events.push(Event::Declaration(pos, name)); - } - - // Sort all events by position - events.sort_by_key(|e| match e { - Event::Namespace(pos, _) => *pos, - Event::Declaration(pos, _) => *pos, - }); - - // Process events in order - for event in events { - match event { - Event::Namespace(_, ns) => { - current_ns = ns; - } - Event::Declaration(_, name) => { - let fqn = if current_ns.is_empty() { - name - } else { - format!("{}\\{}", current_ns, name) - }; - results.push(fqn); - } - } - } - - results -} - -/// Validate that a class file is correctly placed according to PSR-4. -/// -/// - `class`: fully-qualified class name (e.g. `Foo\Bar\Baz`) -/// - `base_namespace`: the PSR-4 namespace prefix (e.g. `Foo\Bar\`) -/// - `file_path`: absolute path to the PHP file -/// - `base_path`: the directory mapped to `base_namespace` (absolute) -/// -/// Returns `true` if the file path matches the PSR-4 mapping. -pub fn validate_psr4_class( - class: &str, - base_namespace: &str, - file_path: &str, - base_path: &str, -) -> bool { - // Normalize the base namespace: ensure it ends with `\` - let base_ns = if base_namespace.is_empty() || base_namespace.ends_with('\\') { - base_namespace.to_string() - } else { - format!("{base_namespace}\\") - }; - - // Class must start with the base namespace - if !class.starts_with(&*base_ns) { - return false; - } - - // The relative class name after the base namespace - let relative_class = &class[base_ns.len()..]; - - // Convert relative class to a relative file path: replace `\` with `/` - let expected_relative = relative_class.replace('\\', "/"); - let expected_file = format!( - "{}/{}.php", - base_path.trim_end_matches('/'), - expected_relative - ); - - // Normalize both paths for comparison (simplistic: just compare strings) - Path::new(file_path) == Path::new(&expected_file) -} - -/// Validate that a class file is correctly placed according to PSR-0. -/// -/// - `class`: fully-qualified class name (e.g. `Foo_Bar_Baz` or `Foo\Bar`) -/// - `file_path`: absolute path to the PHP file -/// - `base_path`: the base directory for PSR-0 lookup -/// -/// Returns `true` if the file path matches the PSR-0 mapping. -pub fn validate_psr0_class(class: &str, file_path: &str, base_path: &str) -> bool { - // PSR-0: namespace separators AND underscores (in class part) map to directory separators. - // Split on `\` first; the last segment may contain underscores that also become `/`. - let parts: Vec<&str> = class.split('\\').collect(); - let relative = if parts.len() == 1 { - // No namespace: underscores in class name become dir separators - parts[0].replace('_', "/") - } else { - let ns_part = parts[..parts.len() - 1].join("/"); - let class_part = parts[parts.len() - 1].replace('_', "/"); - format!("{}/{}", ns_part, class_part) - }; - - let expected_file = format!("{}/{}.php", base_path.trim_end_matches('/'), relative); - Path::new(file_path) == Path::new(&expected_file) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Tests -// ───────────────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - use tempfile::NamedTempFile; - - fn write_php(content: &str) -> NamedTempFile { - let mut f = NamedTempFile::with_suffix(".php").unwrap(); - f.write_all(content.as_bytes()).unwrap(); - f - } - - // ------------------------------------------------------------------------- - // find_classes tests - // ------------------------------------------------------------------------- - - #[test] - fn test_find_classes_simple_class() { - let f = write_php(" Vec { + let mut result = Vec::new(); + if !dir.is_dir() { + return result; + } + collect_php_files_inner(dir, excluded, vendor_dir, project_dir, &mut result); + result +} + +fn collect_php_files_inner( + dir: &Path, + excluded: &[String], + vendor_dir: &Path, + project_dir: &Path, + result: &mut Vec, +) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + + // Check if path matches any excluded pattern + if is_excluded(&path, excluded, vendor_dir, project_dir) { + continue; + } + + if path.is_dir() { + collect_php_files_inner(&path, excluded, vendor_dir, project_dir, result); + } else if crate::php_scanner::is_php_ext(&path) { + result.push(path); + } + } +} + +/// Check whether a path matches any of the excluded patterns. +pub fn is_excluded( + path: &Path, + excluded: &[String], + vendor_dir: &Path, + project_dir: &Path, +) -> bool { + for exc in excluded { + // Excluded patterns can be relative to project_dir or absolute + let exc_path = if Path::new(exc).is_absolute() { + PathBuf::from(exc) + } else { + project_dir.join(exc) + }; + if path.starts_with(&exc_path) || path == exc_path { + return true; + } + // Also check relative to vendor_dir + let exc_vendor = vendor_dir.join(exc); + if path.starts_with(&exc_vendor) || path == exc_vendor { + return true; + } + } + false +} + +/// Scan directories for PHP class declarations and return a classmap. +/// +/// `dirs` is a list of absolute directory paths to scan. +/// Returns a `BTreeMap` where the path expression +/// uses `$vendorDir` or `$baseDir` as appropriate. +pub fn scan_classmap_dirs( + dirs: &[PathBuf], + vendor_dir: &Path, + project_dir: &Path, + excluded: &[String], +) -> BTreeMap { + let mut classmap = BTreeMap::new(); + + for dir in dirs { + let files = collect_php_files(dir, excluded, vendor_dir, project_dir); + for file in files { + match crate::php_scanner::find_classes(&file) { + Ok(classes) => { + for class in classes { + let path_expr = path_to_php_expr(&file, vendor_dir, project_dir); + classmap.entry(class).or_insert(path_expr); + } + } + Err(_) => continue, + } + } + } + + classmap +} + +/// Convert an absolute file path to a PHP path expression using `$vendorDir` or `$baseDir`. +pub fn path_to_php_expr(file: &Path, vendor_dir: &Path, project_dir: &Path) -> String { + if let Ok(rel) = file.strip_prefix(vendor_dir) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + format!("$vendorDir . '/{rel_str}'") + } else if let Ok(rel) = file.strip_prefix(project_dir) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + format!("$baseDir . '/{rel_str}'") + } else { + // Fall back to absolute path + let abs = file.to_string_lossy().replace('\\', "/"); + format!("'{abs}'") + } +} + +/// Convert an absolute file path to a static PHP path expression using `__DIR__ . '/..` form. +pub fn path_to_static_expr(file: &Path, vendor_dir: &Path, project_dir: &Path) -> String { + if let Ok(rel) = file.strip_prefix(vendor_dir) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + format!("__DIR__ . '/..' . '/{rel_str}'") + } else if let Ok(rel) = file.strip_prefix(project_dir) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + format!("__DIR__ . '/../..' . '/{rel_str}'") + } else { + let abs = file.to_string_lossy().replace('\\', "/"); + format!("'{abs}'") + } +} + +/// Scan PSR-4 and PSR-0 directories for class declarations (used in optimize mode). +/// +/// Returns `(dynamic_classmap, static_classmap, psr_violations)`. +pub fn scan_psr_for_classmap( + psr4: &BTreeMap>, + psr0: &BTreeMap>, + vendor_dir: &Path, + project_dir: &Path, + excluded: &[String], +) -> ( + BTreeMap, + BTreeMap, + Vec, +) { + let mut dyn_map: BTreeMap = BTreeMap::new(); + let mut static_map: BTreeMap = BTreeMap::new(); + let mut violations: Vec = Vec::new(); + + // Helper: resolve a PHP path expression to an absolute path. + let resolve = |expr: &str| -> Option { + // Expressions look like: + // $vendorDir . '/psr/log/src' + // $baseDir . '/src' + // __DIR__ . '/..' . '/psr/log/src' + // __DIR__ . '/../..' . '/src' + if let Some(rest) = expr.strip_prefix("$vendorDir . '") { + let rel = rest.trim_end_matches('\''); + Some(vendor_dir.join(rel.trim_start_matches('/'))) + } else if let Some(rest) = expr.strip_prefix("$baseDir . '") { + let rel = rest.trim_end_matches('\''); + Some(project_dir.join(rel.trim_start_matches('/'))) + } else if expr == "$vendorDir" { + Some(vendor_dir.to_path_buf()) + } else if expr == "$baseDir" { + Some(project_dir.to_path_buf()) + } else { + None + } + }; + + // Scan PSR-4 dirs + for (ns, paths) in psr4 { + for path_expr in paths { + if let Some(abs_dir) = resolve(path_expr) { + let files = collect_php_files(&abs_dir, excluded, vendor_dir, project_dir); + for file in files { + match crate::php_scanner::find_classes(&file) { + Ok(classes) => { + for class in classes { + // PSR-4 validation + let file_str = file.to_string_lossy(); + let dir_str = abs_dir.to_string_lossy(); + let base_ns = ns.as_str(); + if !crate::php_scanner::validate_psr4_class( + &class, base_ns, &file_str, &dir_str, + ) { + violations.push(format!( + "Class {class} in {file_str} does not comply with PSR-4 (namespace prefix: {ns})" + )); + } + let dyn_expr = path_to_php_expr(&file, vendor_dir, project_dir); + let static_expr = + path_to_static_expr(&file, vendor_dir, project_dir); + dyn_map.entry(class.clone()).or_insert(dyn_expr); + static_map.entry(class).or_insert(static_expr); + } + } + Err(_) => continue, + } + } + } + } + } + + // Scan PSR-0 dirs + for (ns, paths) in psr0 { + for path_expr in paths { + if let Some(abs_dir) = resolve(path_expr) { + let files = collect_php_files(&abs_dir, excluded, vendor_dir, project_dir); + for file in files { + match crate::php_scanner::find_classes(&file) { + Ok(classes) => { + for class in classes { + let file_str = file.to_string_lossy(); + let dir_str = abs_dir.to_string_lossy(); + if !crate::php_scanner::validate_psr0_class( + &class, &file_str, &dir_str, + ) { + violations.push(format!( + "Class {class} in {file_str} does not comply with PSR-0 (namespace prefix: {ns})" + )); + } + let dyn_expr = path_to_php_expr(&file, vendor_dir, project_dir); + let static_expr = + path_to_static_expr(&file, vendor_dir, project_dir); + dyn_map.entry(class.clone()).or_insert(dyn_expr); + static_map.entry(class).or_insert(static_expr); + } + } + Err(_) => continue, + } + } + } + } + } + + (dyn_map, static_map, violations) +} diff --git a/crates/mozart-class-map-generator/src/lib.rs b/crates/mozart-class-map-generator/src/lib.rs new file mode 100644 index 0000000..a17405c --- /dev/null +++ b/crates/mozart-class-map-generator/src/lib.rs @@ -0,0 +1,8 @@ +pub mod classmap; +pub mod php_scanner; + +pub use classmap::{ + collect_php_files, is_excluded, path_to_php_expr, path_to_static_expr, scan_classmap_dirs, + scan_psr_for_classmap, +}; +pub use php_scanner::{find_classes, is_php_ext, validate_psr0_class, validate_psr4_class}; diff --git a/crates/mozart-class-map-generator/src/php_scanner.rs b/crates/mozart-class-map-generator/src/php_scanner.rs new file mode 100644 index 0000000..3d0d51d --- /dev/null +++ b/crates/mozart-class-map-generator/src/php_scanner.rs @@ -0,0 +1,629 @@ +use anyhow::Result; +use regex::Regex; +use std::path::Path; + +/// File extensions considered PHP source files for class scanning. +const PHP_EXTENSIONS: &[&str] = &["php", "inc", "hh"]; + +/// Check if a file path has a PHP-like extension. +fn is_php_file(path: &Path) -> bool { + is_php_ext(path) +} + +/// Public version of the PHP extension check, used by the autoload scanner. +pub fn is_php_ext(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|ext| PHP_EXTENSIONS.iter().any(|&e| ext.eq_ignore_ascii_case(e))) + .unwrap_or(false) +} + +/// Scan a PHP file and return the list of fully-qualified class names declared in it. +/// +/// Returns an empty vec if the file has no relevant extension or no class declarations. +pub fn find_classes(path: &Path) -> Result> { + if !is_php_file(path) { + return Ok(vec![]); + } + + let contents = std::fs::read_to_string(path)?; + + // Quick check: does the file even contain a class-like keyword? + let quick_re = Regex::new(r"(?i)\b(?:class|interface|trait|enum)\s").unwrap(); + if !quick_re.is_match(&contents) { + return Ok(vec![]); + } + + let cleaned = clean_php_content(&contents); + Ok(extract_declarations(&cleaned)) +} + +/// State machine that strips strings, comments, and heredocs/nowdocs from PHP code. +/// +/// Returns a string of equal byte length where non-PHP content is replaced with spaces +/// so that regex offsets are preserved. Only PHP mode content is kept; everything else +/// is blanked out. +fn clean_php_content(contents: &str) -> String { + let bytes = contents.as_bytes(); + let len = bytes.len(); + let mut out = vec![b' '; len]; + let mut i = 0; + let mut in_php = false; + + while i < len { + if !in_php { + // Look for `` + if i + 1 < len && bytes[i] == b'?' && bytes[i + 1] == b'>' { + in_php = false; + i += 2; + continue; + } + + // Line comment: // or # + if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'/' { + // Skip to end of line + while i < len && bytes[i] != b'\n' { + i += 1; + } + continue; + } + if bytes[i] == b'#' { + while i < len && bytes[i] != b'\n' { + i += 1; + } + continue; + } + + // Block comment: /* ... */ + if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' { + i += 2; + while i + 1 < len { + if bytes[i] == b'*' && bytes[i + 1] == b'/' { + i += 2; + break; + } + i += 1; + } + continue; + } + + // Single-quoted string + if bytes[i] == b'\'' { + out[i] = b'\''; + i += 1; + while i < len { + if bytes[i] == b'\\' && i + 1 < len { + // escaped character — blank both + i += 2; + } else if bytes[i] == b'\'' { + out[i] = b'\''; + i += 1; + break; + } else { + i += 1; + } + } + continue; + } + + // Double-quoted string + if bytes[i] == b'"' { + out[i] = b'"'; + i += 1; + while i < len { + if bytes[i] == b'\\' && i + 1 < len { + i += 2; + } else if bytes[i] == b'"' { + out[i] = b'"'; + i += 1; + break; + } else { + i += 1; + } + } + continue; + } + + // Heredoc / Nowdoc: <<< + if i + 2 < len && bytes[i] == b'<' && bytes[i + 1] == b'<' && bytes[i + 2] == b'<' { + i += 3; + // Skip whitespace + while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + + // Nowdoc uses single quotes around label; heredoc may use double quotes. + let is_nowdoc = i < len && bytes[i] == b'\''; + // Skip optional opening quote (single for nowdoc, double for heredoc) + if i < len && (bytes[i] == b'\'' || bytes[i] == b'"') { + i += 1; + } + + // Read label + let label_start = i; + while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') { + i += 1; + } + let label = std::str::from_utf8(&bytes[label_start..i]) + .unwrap_or("") + .to_string(); + + // Skip closing quote of label (must match the opening quote) + let expected_close = if is_nowdoc { b'\'' } else { b'"' }; + if i < len && bytes[i] == expected_close { + i += 1; + } + + // Skip to end of line + while i < len && bytes[i] != b'\n' { + i += 1; + } + if i < len { + i += 1; // consume newline + } + + // Scan for the terminator label on its own line + if !label.is_empty() { + loop { + if i >= len { + break; + } + // Check if current line starts with the label + let line_start = i; + // Skip optional whitespace for indented heredoc (PHP 7.3+) + while i < len && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + let remaining = &bytes[i..]; + let label_bytes = label.as_bytes(); + if remaining.len() >= label_bytes.len() + && &remaining[..label_bytes.len()] == label_bytes + { + let after = i + label_bytes.len(); + // Terminator must be followed by ; or newline or EOF + if after >= len + || bytes[after] == b';' + || bytes[after] == b'\n' + || bytes[after] == b'\r' + { + // Skip to end of this line + i = after; + while i < len && bytes[i] != b'\n' { + i += 1; + } + if i < len { + i += 1; + } + break; + } + } + // Not a terminator line — skip to end of line + i = line_start; + while i < len && bytes[i] != b'\n' { + i += 1; + } + if i < len { + i += 1; + } + } + } + continue; + } + + // Backtick strings (shell exec) + if bytes[i] == b'`' { + out[i] = b'`'; + i += 1; + while i < len { + if bytes[i] == b'\\' && i + 1 < len { + i += 2; + } else if bytes[i] == b'`' { + out[i] = b'`'; + i += 1; + break; + } else { + i += 1; + } + } + continue; + } + + // Keep normal PHP content + out[i] = bytes[i]; + i += 1; + } + + String::from_utf8_lossy(&out).into_owned() +} + +/// Extract fully-qualified class names from cleaned PHP content. +/// +/// Tracks the current namespace and finds class/interface/trait/enum declarations. +fn extract_declarations(cleaned: &str) -> Vec { + let mut results = Vec::new(); + + // Regex for namespace declarations: + // namespace Foo\Bar; — simple + // namespace Foo\Bar { — block + // namespace { — global block + let ns_re = Regex::new( + r"(?x) + \bnamespace\s+ + ((?:[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*\\)*[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*) + \s*[;{] + | + \bnamespace\s*\{ + ", + ) + .unwrap(); + + // Regex for class/interface/trait/enum declarations. + // We need to capture the name; anonymous classes (new class ...) are excluded. + let decl_re = Regex::new( + r"(?x) + \b(?:abstract\s+|final\s+|readonly\s+)* + (?Pclass|interface|trait|enum)\s+ + (?P[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*) + ", + ) + .unwrap(); + + let mut current_ns = String::new(); + + // We process namespace changes as we walk through the file. + // Build a list of all namespace and declaration positions. + #[derive(Debug)] + enum Event { + Namespace(usize, String), // position, namespace + Declaration(usize, String), // position, simple name + } + + let mut events: Vec = Vec::new(); + + // Find namespace declarations + for cap in ns_re.captures_iter(cleaned) { + let pos = cap.get(0).unwrap().start(); + let ns_name = cap + .get(1) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + events.push(Event::Namespace(pos, ns_name)); + } + + // Find class/interface/trait/enum declarations + for cap in decl_re.captures_iter(cleaned) { + let pos = cap.get(0).unwrap().start(); + let name = cap.name("name").unwrap().as_str().to_string(); + + // Skip anonymous classes: check if "new" precedes "class" on the same "expression". + // A reliable check: look back for "new " before this match. + let before = &cleaned[..pos]; + let kind = cap.name("kind").unwrap().as_str(); + if kind == "class" { + // Check if "new" appears right before (with possible whitespace/modifiers). + // Simple heuristic: scan backwards for non-whitespace token. + let trimmed = before.trim_end(); + if trimmed.ends_with("new") { + continue; + } + } + + events.push(Event::Declaration(pos, name)); + } + + // Sort all events by position + events.sort_by_key(|e| match e { + Event::Namespace(pos, _) => *pos, + Event::Declaration(pos, _) => *pos, + }); + + // Process events in order + for event in events { + match event { + Event::Namespace(_, ns) => { + current_ns = ns; + } + Event::Declaration(_, name) => { + let fqn = if current_ns.is_empty() { + name + } else { + format!("{}\\{}", current_ns, name) + }; + results.push(fqn); + } + } + } + + results +} + +/// Validate that a class file is correctly placed according to PSR-4. +/// +/// - `class`: fully-qualified class name (e.g. `Foo\Bar\Baz`) +/// - `base_namespace`: the PSR-4 namespace prefix (e.g. `Foo\Bar\`) +/// - `file_path`: absolute path to the PHP file +/// - `base_path`: the directory mapped to `base_namespace` (absolute) +/// +/// Returns `true` if the file path matches the PSR-4 mapping. +pub fn validate_psr4_class( + class: &str, + base_namespace: &str, + file_path: &str, + base_path: &str, +) -> bool { + // Normalize the base namespace: ensure it ends with `\` + let base_ns = if base_namespace.is_empty() || base_namespace.ends_with('\\') { + base_namespace.to_string() + } else { + format!("{base_namespace}\\") + }; + + // Class must start with the base namespace + if !class.starts_with(&*base_ns) { + return false; + } + + // The relative class name after the base namespace + let relative_class = &class[base_ns.len()..]; + + // Convert relative class to a relative file path: replace `\` with `/` + let expected_relative = relative_class.replace('\\', "/"); + let expected_file = format!( + "{}/{}.php", + base_path.trim_end_matches('/'), + expected_relative + ); + + // Normalize both paths for comparison (simplistic: just compare strings) + Path::new(file_path) == Path::new(&expected_file) +} + +/// Validate that a class file is correctly placed according to PSR-0. +/// +/// - `class`: fully-qualified class name (e.g. `Foo_Bar_Baz` or `Foo\Bar`) +/// - `file_path`: absolute path to the PHP file +/// - `base_path`: the base directory for PSR-0 lookup +/// +/// Returns `true` if the file path matches the PSR-0 mapping. +pub fn validate_psr0_class(class: &str, file_path: &str, base_path: &str) -> bool { + // PSR-0: namespace separators AND underscores (in class part) map to directory separators. + // Split on `\` first; the last segment may contain underscores that also become `/`. + let parts: Vec<&str> = class.split('\\').collect(); + let relative = if parts.len() == 1 { + // No namespace: underscores in class name become dir separators + parts[0].replace('_', "/") + } else { + let ns_part = parts[..parts.len() - 1].join("/"); + let class_part = parts[parts.len() - 1].replace('_', "/"); + format!("{}/{}", ns_part, class_part) + }; + + let expected_file = format!("{}/{}.php", base_path.trim_end_matches('/'), relative); + Path::new(file_path) == Path::new(&expected_file) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_php(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::with_suffix(".php").unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f + } + + // ------------------------------------------------------------------------- + // find_classes tests + // ------------------------------------------------------------------------- + + #[test] + fn test_find_classes_simple_class() { + let f = write_php(", - /// true for "dev-master", "dev-feature/foo", etc. - pub is_dev_branch: bool, - /// The original branch name for dev branches (e.g. "master", "feature/foo") - pub dev_branch_name: Option, -} - -/// Stability rank for ordering (lower = more stable). -fn stability_rank(pre: &str) -> u8 { - let lower = pre.to_lowercase(); - if lower.starts_with("dev") { - 50 - } else if lower.starts_with("alpha") || lower.starts_with("a") { - 40 - } else if lower.starts_with("beta") || lower.starts_with("b") { - 30 - } else if lower.starts_with("rc") { - 20 - } else if lower.starts_with("patch") || lower.starts_with("pl") || lower == "p" { - 5 - } else { - 0 - } -} - -/// Extract numeric suffix from a pre-release string like "alpha1" → 1, "beta" → 0 -fn pre_release_number(pre: &str) -> u64 { - let digits: String = pre.chars().skip_while(|c| c.is_alphabetic()).collect(); - digits.parse().unwrap_or(0) -} - -impl PartialOrd for Version { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for Version { - fn cmp(&self, other: &Self) -> Ordering { - // Dev branches are always lowest - match (self.is_dev_branch, other.is_dev_branch) { - (true, true) => { - // Compare branch names - return self.dev_branch_name.cmp(&other.dev_branch_name); - } - (true, false) => return Ordering::Less, - (false, true) => return Ordering::Greater, - (false, false) => {} - } - - // Compare numeric segments - let num_cmp = (self.major, self.minor, self.patch, self.build).cmp(&( - other.major, - other.minor, - other.patch, - other.build, - )); - if num_cmp != Ordering::Equal { - return num_cmp; - } - - // Compare pre-release: None (stable) > any pre-release - match (&self.pre_release, &other.pre_release) { - (None, None) => Ordering::Equal, - (None, Some(_)) => Ordering::Greater, - (Some(_), None) => Ordering::Less, - (Some(a), Some(b)) => { - let rank_a = stability_rank(a); - let rank_b = stability_rank(b); - match rank_a.cmp(&rank_b) { - Ordering::Equal => { - // Same stability: compare numeric suffix - pre_release_number(a).cmp(&pre_release_number(b)) - } - // Lower rank = more stable = greater version - Ordering::Less => Ordering::Greater, - Ordering::Greater => Ordering::Less, - } - } - } - } -} - -impl Version { - /// Parse a version string into a `Version` struct using Composer normalization rules. - /// - /// For inline aliases (`"1.0.x-dev as 1.0.0"`), the LEFT side (the real branch version) - /// is used. This is the correct behaviour for identifying *what* version a package provides. - pub fn parse(input: &str) -> Result { - let s = input.trim(); - - // Strip inline alias: "1.0.x-dev as 1.0.0" → "1.0.x-dev" - let s = if let Some(pos) = s.find(" as ") { - &s[..pos] - } else { - s - }; - - // Strip stability flag: "@dev", "@alpha", "@beta", "@RC", "@stable" - let s = if let Some(pos) = s.rfind('@') { - let after = &s[pos + 1..]; - let known = ["dev", "alpha", "beta", "rc", "stable"]; - if known.iter().any(|k| after.eq_ignore_ascii_case(k)) { - &s[..pos] - } else { - s - } - } else { - s - }; - - // Handle dev-* prefix branches - if s.to_lowercase().starts_with("dev-") { - let branch = &s[4..]; - return Ok(Version { - major: 0, - minor: 0, - patch: 0, - build: 0, - pre_release: Some("dev".to_string()), - is_dev_branch: true, - dev_branch_name: Some(branch.to_string()), - }); - } - - // Handle *-dev suffix (e.g., "2.1.x-dev" or "2.x-dev") - let s_lower = s.to_lowercase(); - if s_lower.ends_with("-dev") || s_lower.ends_with(".x-dev") { - let base = if s_lower.ends_with("-dev") { - &s[..s.len() - 4] - } else { - s - }; - // Replace any trailing .x with nothing, parse numeric parts - let base = base.trim_end_matches(".x").trim_end_matches("-dev"); - let parts: Vec<&str> = base.split('.').collect(); - let major = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); - let minor = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - return Ok(Version { - major, - minor, - patch: 9999999, - build: 9999999, - pre_release: Some("dev".to_string()), - is_dev_branch: true, - dev_branch_name: None, - }); - } - - // Strip leading v/V - let s = s - .strip_prefix('v') - .or_else(|| s.strip_prefix('V')) - .unwrap_or(s); - - // Strip build metadata after + - let s = s.split('+').next().unwrap_or(s); - - // Parse the version using regex-like approach - parse_classical_version(s) - } - - /// Parse a version string for use inside a *constraint expression*. - /// - /// The difference from [`Version::parse`] is the treatment of inline aliases: - /// `"1.0.x-dev as 1.0.0"` → takes the **right** side (`1.0.0`). - /// - /// Inline aliases appear in `require` fields like: - /// ```text - /// "some/package": "1.0.x-dev as 1.0.0" - /// ``` - /// Here the author wants the constraint to be satisfied by the real version `1.0.0`, - /// while the left side (`1.0.x-dev`) indicates the branch that provides it. - pub fn parse_for_constraint(input: &str) -> Result { - let s = input.trim(); - // For inline aliases, take the RIGHT side (alias target) - let s = if let Some(pos) = s.find(" as ") { - s[pos + 4..].trim() - } else { - s - }; - Version::parse(s) - } - - /// Create a "dev boundary" version for constraint matching (major.minor.patch.build with dev pre-release). - pub fn dev_boundary(major: u64, minor: u64, patch: u64, build: u64) -> Version { - Version { - major, - minor, - patch, - build, - pre_release: Some("dev".to_string()), - is_dev_branch: false, - dev_branch_name: None, - } - } -} - -fn parse_classical_version(s: &str) -> Result { - // Split on '-' to separate version from pre-release - let (version_part, pre_part) = if let Some(pos) = s.find('-') { - (&s[..pos], Some(&s[pos + 1..])) - } else { - (s, None) - }; - - let segments: Vec<&str> = version_part.split('.').collect(); - if segments.is_empty() || segments[0].is_empty() { - return Err(format!("Invalid version: {s}")); - } - - let major: u64 = segments[0] - .parse() - .map_err(|_| format!("Invalid major version segment: {}", segments[0]))?; - let minor: u64 = segments.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - let patch: u64 = segments - .get(2) - .and_then(|p| { - // strip trailing .x - let p = p.trim_end_matches('x').trim_end_matches('.'); - if p.is_empty() { - Some(0) - } else { - p.parse().ok() - } - }) - .unwrap_or(0); - let build: u64 = segments.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); - - let pre_release = pre_part.map(normalize_pre_release); - - Ok(Version { - major, - minor, - patch, - build, - pre_release, - is_dev_branch: false, - dev_branch_name: None, - }) -} - -fn normalize_pre_release(s: &str) -> String { - // Normalize aliases: b→beta, a→alpha, rc→RC, p/pl/patch→patch - let lower = s.to_lowercase(); - // Strip leading non-alpha characters (dots, underscores, dashes used as separators) - let normalized = lower - .trim_start_matches(|c: char| !c.is_alphabetic()) - .to_string(); - - // Extract the alphabetic prefix (stability name) - let alpha: String = normalized - .chars() - .take_while(|c| c.is_alphabetic()) - .collect(); - // Extract only digits from the rest (strip separators like dots) - let num: String = normalized - .chars() - .skip_while(|c| c.is_alphabetic()) - .filter(|c| c.is_ascii_digit()) - .collect(); - - if alpha.starts_with("beta") || alpha == "b" { - format!("beta{num}") - } else if alpha.starts_with("alpha") || alpha == "a" { - format!("alpha{num}") - } else if alpha == "rc" { - format!("RC{num}") - } else if alpha == "patch" || alpha == "pl" || alpha == "p" { - format!("patch{num}") - } else if alpha == "dev" { - "dev".to_string() - } else { - s.to_string() - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Constraint types -// ───────────────────────────────────────────────────────────────────────────── - -/// A single atomic constraint. -#[derive(Debug, Clone)] -pub enum Constraint { - /// Exact version match - Exact(Version), - /// Greater than: `> 1.2.3` - GreaterThan(Version), - /// Greater than or equal: `>= 1.2.3` - GreaterThanOrEqual(Version), - /// Less than: `< 1.2.3` - LessThan(Version), - /// Less than or equal: `<= 1.2.3` - LessThanOrEqual(Version), - /// Not equal: `!= 1.2.3` - NotEqual(Version), - /// Matches any version - Any, -} - -impl Constraint { - pub fn matches(&self, v: &Version) -> bool { - match self { - Constraint::Exact(target) => v == target, - Constraint::GreaterThan(target) => v > target, - Constraint::GreaterThanOrEqual(target) => v >= target, - Constraint::LessThan(target) => v < target, - Constraint::LessThanOrEqual(target) => v <= target, - Constraint::NotEqual(target) => v != target, - Constraint::Any => true, - } - } -} - -/// A compound constraint with AND/OR combinators. -#[derive(Debug, Clone)] -pub enum VersionConstraint { - /// Single atomic constraint - Single(Constraint), - /// All must match (AND — space/comma separated) - And(Vec), - /// At least one must match (OR — `||` separated) - Or(Vec), -} - -impl VersionConstraint { - pub fn matches(&self, version: &Version) -> bool { - match self { - VersionConstraint::Single(c) => c.matches(version), - VersionConstraint::And(cs) => cs.iter().all(|c| c.matches(version)), - VersionConstraint::Or(cs) => cs.iter().any(|c| c.matches(version)), - } - } - - /// Parse a constraint string like `^1.2`, `>=1.0 <2.0`, `^1.0 || ^2.0`. - pub fn parse(input: &str) -> Result { - let input = input.trim(); - - // Split on || (OR) - let or_parts: Vec<&str> = split_or(input); - - if or_parts.len() > 1 { - let constraints: Result, _> = - or_parts.iter().map(|p| parse_and_group(p.trim())).collect(); - let mut cs = constraints?; - // Flatten single-element groups - if cs.len() == 1 { - return Ok(cs.remove(0)); - } - return Ok(VersionConstraint::Or(cs)); - } - - parse_and_group(input) - } -} - -/// Split on `|` or `||` (pipe-OR). Composer accepts both forms. -fn split_or(s: &str) -> Vec<&str> { - let mut parts = Vec::new(); - let mut start = 0; - let bytes = s.as_bytes(); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'|' { - parts.push(s[start..i].trim()); - i += 1; - // Skip second pipe if `||` - if i < bytes.len() && bytes[i] == b'|' { - i += 1; - } - start = i; - } else { - i += 1; - } - } - parts.push(s[start..].trim()); - // Filter out empty parts (e.g. from leading/trailing pipes) - parts.into_iter().filter(|p| !p.is_empty()).collect() -} - -/// Parse an AND group (space or comma separated constraints). -fn parse_and_group(s: &str) -> Result { - // Detect inline alias first: "1.0.x-dev as 1.0.0" - // The entire expression is a single atomic constraint; parse it directly. - if s.contains(" as ") { - return parse_single(s); - } - - // Detect hyphen range first: "1.0 - 2.0" where both sides start with a digit - if let Some(idx) = s.find(" - ") { - let before = s[..idx].trim(); - let after = s[idx + 3..].trim(); - let before_is_version = before - .chars() - .next() - .is_some_and(|c| c.is_ascii_digit() || c == 'v' || c == 'V'); - let after_is_version = after - .chars() - .next() - .is_some_and(|c| c.is_ascii_digit() || c == 'v' || c == 'V'); - if before_is_version && after_is_version { - return parse_hyphen_range(s); - } - } - - let parts = split_and(s); - - if parts.is_empty() { - return Err("Empty constraint".to_string()); - } - - let constraints: Result, _> = parts.iter().map(|p| parse_single(p.trim())).collect(); - let mut cs = constraints?; - - if cs.len() == 1 { - return Ok(cs.remove(0)); - } - - // Flatten nested And - let flat: Vec = cs - .into_iter() - .flat_map(|c| match c { - VersionConstraint::And(inner) => inner, - other => vec![other], - }) - .collect(); - - Ok(VersionConstraint::And(flat)) -} - -/// Split on spaces or commas (AND separator), respecting that version strings -/// can contain `-` (pre-release). -fn split_and(s: &str) -> Vec { - // A constraint "part" is separated by space or comma when not part of - // operator prefixes like `>=`, `<=`, `!=`, or version like `1.2.3-beta`. - // Strategy: tokenize by whitespace/comma, then re-join multi-token ranges. - let tokens: Vec<&str> = s.split([' ', ',']).filter(|t| !t.is_empty()).collect(); - - let mut parts: Vec = Vec::new(); - let mut current = String::new(); - - for token in tokens { - if current.is_empty() { - current = token.to_string(); - } else { - // If the token starts with an operator or a digit/^ ~/>, it's a new constraint - let starts_new = token.starts_with(|c: char| { - matches!(c, '>' | '<' | '!' | '=' | '^' | '~' | '*') || c.is_ascii_digit() - }); - if starts_new { - parts.push(current.trim().to_string()); - current = token.to_string(); - } else { - // Continuation (e.g. part of a version string with spaces) - current.push(' '); - current.push_str(token); - } - } - } - if !current.is_empty() { - parts.push(current.trim().to_string()); - } - - parts -} - -/// Parse a single constraint part. -fn parse_single(s: &str) -> Result { - if s == "*" || s.is_empty() { - return Ok(VersionConstraint::Single(Constraint::Any)); - } - - // Caret: ^1.2.3 - if let Some(rest) = s.strip_prefix('^') { - return parse_caret(rest); - } - - // Tilde: ~1.2.3 - if let Some(rest) = s.strip_prefix('~') { - return parse_tilde(rest); - } - - // Hyphen range: "1.0 - 2.0" — handled at and-group level, but check here too - if s.contains(" - ") { - return parse_hyphen_range(s); - } - - // Comparison operators - // Use parse_for_constraint so that inline aliases like "1.0.x-dev as 1.0.0" - // resolve to the alias target (right-hand side) when used in constraint context. - if let Some(rest) = s.strip_prefix(">=") { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::GreaterThanOrEqual(v))); - } - if let Some(rest) = s.strip_prefix("<=") { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::LessThanOrEqual(v))); - } - if let Some(rest) = s.strip_prefix("!=") { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::NotEqual(v))); - } - if let Some(rest) = s.strip_prefix('>') { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::GreaterThan(v))); - } - if let Some(rest) = s.strip_prefix('<') { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::LessThan(v))); - } - if let Some(rest) = s.strip_prefix('=') { - let v = Version::parse_for_constraint(rest.trim())?; - return Ok(VersionConstraint::Single(Constraint::Exact(v))); - } - - // Wildcard: 1.2.* or 1.* - if s.ends_with(".*") || s.ends_with(".*.*") || s == "*" { - return parse_wildcard(s); - } - - // Exact version (may carry an inline alias; take the alias target for matching) - let v = Version::parse_for_constraint(s)?; - Ok(VersionConstraint::Single(Constraint::Exact(v))) -} - -/// Parse `^major.minor.patch` caret constraint. -/// First non-zero segment is the "locked" boundary. -fn parse_caret(s: &str) -> Result { - let parts: Vec<&str> = s.split('.').collect(); - let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); - let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); - let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); - - let lower = Version::dev_boundary(major, minor, patch, build); - - // Determine upper bound based on first non-zero segment - let upper = if major > 0 { - Version::dev_boundary(major + 1, 0, 0, 0) - } else if minor > 0 { - Version::dev_boundary(0, minor + 1, 0, 0) - } else if patch > 0 { - Version::dev_boundary(0, 0, patch + 1, 0) - } else { - Version::dev_boundary(0, 0, 1, 0) - }; - - Ok(VersionConstraint::And(vec![ - VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), - VersionConstraint::Single(Constraint::LessThan(upper)), - ])) -} - -/// Parse `~major.minor.patch` tilde constraint. -fn parse_tilde(s: &str) -> Result { - let parts: Vec<&str> = s.split('.').collect(); - let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); - let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); - let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); - - let lower = Version::dev_boundary(major, minor, patch, build); - - // ~major.minor.patch → >=major.minor.patch =major.minor.0 <(major+1).0.0 - // ~major → >=major.0.0 <(major+1).0.0 - let upper = if parts.len() >= 3 { - Version::dev_boundary(major, minor + 1, 0, 0) - } else { - Version::dev_boundary(major + 1, 0, 0, 0) - }; - - Ok(VersionConstraint::And(vec![ - VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), - VersionConstraint::Single(Constraint::LessThan(upper)), - ])) -} - -/// Parse `1.2.*` wildcard constraint. -fn parse_wildcard(s: &str) -> Result { - if s == "*" { - return Ok(VersionConstraint::Single(Constraint::Any)); - } - - // Strip trailing .* - let base = s.trim_end_matches(".*"); - if base.is_empty() { - return Ok(VersionConstraint::Single(Constraint::Any)); - } - - let parts: Vec<&str> = base.split('.').collect(); - let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); - let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - - let (lower, upper) = if parts.len() == 1 { - ( - Version::dev_boundary(major, 0, 0, 0), - Version::dev_boundary(major + 1, 0, 0, 0), - ) - } else { - ( - Version::dev_boundary(major, minor, 0, 0), - Version::dev_boundary(major, minor + 1, 0, 0), - ) - }; - - Ok(VersionConstraint::And(vec![ - VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), - VersionConstraint::Single(Constraint::LessThan(upper)), - ])) -} - -/// Parse `1.0 - 2.0` hyphen range. -fn parse_hyphen_range(s: &str) -> Result { - let parts: Vec<&str> = s.splitn(2, " - ").collect(); - if parts.len() != 2 { - return Err(format!("Invalid hyphen range: {s}")); - } - - let lower_v = Version::parse_for_constraint(parts[0].trim())?; - let upper_v = Version::parse_for_constraint(parts[1].trim())?; - - Ok(VersionConstraint::And(vec![ - VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower_v)), - VersionConstraint::Single(Constraint::LessThanOrEqual(upper_v)), - ])) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ──────────── Version parsing ──────────── - - #[test] - fn test_parse_simple() { - let v = Version::parse("1.2.3").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - assert_eq!(v.build, 0); - assert_eq!(v.pre_release, None); - assert!(!v.is_dev_branch); - } - - #[test] - fn test_parse_with_v_prefix() { - let v = Version::parse("v1.2").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 0); - assert_eq!(v.build, 0); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_four_segments() { - let v = Version::parse("1.2.3.4").unwrap(); - assert_eq!((v.major, v.minor, v.patch, v.build), (1, 2, 3, 4)); - } - - #[test] - fn test_parse_beta() { - let v = Version::parse("1.0.0-beta.1").unwrap(); - assert_eq!(v.major, 1); - // "beta.1" normalizes to "beta1" (dot is stripped) - assert_eq!(v.pre_release, Some("beta1".to_string())); - } - - #[test] - fn test_parse_beta1() { - let v = Version::parse("1.0.0-beta1").unwrap(); - assert_eq!(v.pre_release, Some("beta1".to_string())); - } - - #[test] - fn test_parse_rc() { - let v = Version::parse("1.0.0-RC1").unwrap(); - assert_eq!(v.pre_release, Some("RC1".to_string())); - } - - #[test] - fn test_parse_alpha() { - let v = Version::parse("2.0.0-alpha3").unwrap(); - assert_eq!(v.pre_release, Some("alpha3".to_string())); - } - - #[test] - fn test_parse_dev_master() { - let v = Version::parse("dev-master").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.dev_branch_name, Some("master".to_string())); - assert_eq!(v.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_dev_feature() { - let v = Version::parse("dev-feature/foo").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.dev_branch_name, Some("feature/foo".to_string())); - } - - #[test] - fn test_parse_x_dev() { - let v = Version::parse("2.1.x-dev").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.major, 2); - assert_eq!(v.minor, 1); - assert_eq!(v.patch, 9999999); - assert_eq!(v.build, 9999999); - } - - #[test] - fn test_parse_strip_at_stability() { - let v = Version::parse("1.2.3@stable").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_inline_alias() { - let v = Version::parse("1.0.x-dev as 1.0.0").unwrap(); - // Takes left side: 1.0.x-dev - assert!(v.is_dev_branch); - } - - #[test] - fn test_parse_for_constraint_inline_alias() { - // parse_for_constraint takes the RIGHT side of an inline alias - let v = Version::parse_for_constraint("1.0.x-dev as 1.0.0").unwrap(); - assert!(!v.is_dev_branch); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_for_constraint_no_alias() { - // Without an alias, parse_for_constraint behaves like parse - let v = Version::parse_for_constraint("1.2.3").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - assert!(!v.is_dev_branch); - } - - #[test] - fn test_constraint_inline_alias_exact_matches_target() { - // A constraint written as "1.0.x-dev as 1.0.0" should match 1.0.0 (the alias target) - let c = VersionConstraint::parse("1.0.x-dev as 1.0.0").unwrap(); - let target = Version::parse("1.0.0").unwrap(); - assert!(c.matches(&target)); - // But NOT a different version - let other = Version::parse("1.1.0").unwrap(); - assert!(!c.matches(&other)); - } - - // ──────────── Version ordering ──────────── - - #[test] - fn test_ordering_major() { - let a = Version::parse("2.0.0").unwrap(); - let b = Version::parse("1.0.0").unwrap(); - assert!(a > b); - } - - #[test] - fn test_ordering_minor() { - let a = Version::parse("1.2.0").unwrap(); - let b = Version::parse("1.1.0").unwrap(); - assert!(a > b); - } - - #[test] - fn test_ordering_stable_gt_rc() { - let stable = Version::parse("1.0.0").unwrap(); - let rc = Version::parse("1.0.0-RC1").unwrap(); - assert!(stable > rc); - } - - #[test] - fn test_ordering_rc_gt_beta() { - let rc = Version::parse("1.0.0-RC1").unwrap(); - let beta = Version::parse("1.0.0-beta1").unwrap(); - assert!(rc > beta); - } - - #[test] - fn test_ordering_beta_gt_alpha() { - let beta = Version::parse("1.0.0-beta1").unwrap(); - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - assert!(beta > alpha); - } - - #[test] - fn test_ordering_alpha_gt_dev_branch() { - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - let dev = Version::parse("dev-master").unwrap(); - assert!(alpha > dev); - } - - #[test] - fn test_ordering_pre_release_numbers() { - let beta2 = Version::parse("1.0.0-beta2").unwrap(); - let beta1 = Version::parse("1.0.0-beta1").unwrap(); - assert!(beta2 > beta1); - } - - // ──────────── Constraint parsing ──────────── - - #[test] - fn test_parse_any() { - let c = VersionConstraint::parse("*").unwrap(); - let v = Version::parse("1.2.3").unwrap(); - assert!(c.matches(&v)); - } - - #[test] - fn test_parse_exact() { - let c = VersionConstraint::parse("1.2.3").unwrap(); - let v = Version::parse("1.2.3").unwrap(); - assert!(c.matches(&v)); - let v2 = Version::parse("1.2.4").unwrap(); - assert!(!c.matches(&v2)); - } - - #[test] - fn test_parse_gte() { - let c = VersionConstraint::parse(">=1.0.0").unwrap(); - assert!(c.matches(&Version::parse("1.0.0").unwrap())); - assert!(c.matches(&Version::parse("2.0.0").unwrap())); - assert!(!c.matches(&Version::parse("0.9.0").unwrap())); - } - - #[test] - fn test_parse_caret_major() { - let c = VersionConstraint::parse("^1.2").unwrap(); - assert!(c.matches(&Version::parse("1.2.0").unwrap())); - assert!(c.matches(&Version::parse("1.3.0").unwrap())); - assert!(c.matches(&Version::parse("1.9.9").unwrap())); - assert!(!c.matches(&Version::parse("2.0.0").unwrap())); - assert!(!c.matches(&Version::parse("1.1.0").unwrap())); - } - - #[test] - fn test_parse_caret_zero_minor() { - // ^0.2.3 → >=0.2.3 <0.3.0 - let c = VersionConstraint::parse("^0.2.3").unwrap(); - assert!(c.matches(&Version::parse("0.2.3").unwrap())); - assert!(c.matches(&Version::parse("0.2.9").unwrap())); - assert!(!c.matches(&Version::parse("0.3.0").unwrap())); - assert!(!c.matches(&Version::parse("1.0.0").unwrap())); - } - - #[test] - fn test_parse_tilde_three_parts() { - // ~1.2.3 → >=1.2.3 <1.3.0 - let c = VersionConstraint::parse("~1.2.3").unwrap(); - assert!(c.matches(&Version::parse("1.2.3").unwrap())); - assert!(c.matches(&Version::parse("1.2.9").unwrap())); - assert!(!c.matches(&Version::parse("1.3.0").unwrap())); - } - - #[test] - fn test_parse_tilde_two_parts() { - // ~1.2 → >=1.2.0 <2.0.0 - let c = VersionConstraint::parse("~1.2").unwrap(); - assert!(c.matches(&Version::parse("1.2.0").unwrap())); - assert!(c.matches(&Version::parse("1.9.0").unwrap())); - assert!(!c.matches(&Version::parse("2.0.0").unwrap())); - } - - #[test] - fn test_parse_wildcard() { - let c = VersionConstraint::parse("1.2.*").unwrap(); - assert!(c.matches(&Version::parse("1.2.0").unwrap())); - assert!(c.matches(&Version::parse("1.2.9").unwrap())); - assert!(!c.matches(&Version::parse("1.3.0").unwrap())); - } - - #[test] - fn test_parse_and() { - let c = VersionConstraint::parse(">=1.0 <2.0").unwrap(); - assert!(c.matches(&Version::parse("1.0.0").unwrap())); - assert!(c.matches(&Version::parse("1.9.9").unwrap())); - assert!(!c.matches(&Version::parse("2.0.0").unwrap())); - assert!(!c.matches(&Version::parse("0.9.9").unwrap())); - } - - #[test] - fn test_parse_or() { - let c = VersionConstraint::parse("^1.0 || ^2.0").unwrap(); - assert!(c.matches(&Version::parse("1.5.0").unwrap())); - assert!(c.matches(&Version::parse("2.3.0").unwrap())); - assert!(!c.matches(&Version::parse("3.0.0").unwrap())); - } - - #[test] - fn test_parse_not_equal() { - let c = VersionConstraint::parse("!=1.5.0").unwrap(); - assert!(c.matches(&Version::parse("1.4.0").unwrap())); - assert!(!c.matches(&Version::parse("1.5.0").unwrap())); - assert!(c.matches(&Version::parse("1.6.0").unwrap())); - } - - #[test] - fn test_parse_hyphen_range() { - let c = VersionConstraint::parse("1.0 - 2.0").unwrap(); - assert!(c.matches(&Version::parse("1.0.0").unwrap())); - assert!(c.matches(&Version::parse("1.5.0").unwrap())); - assert!(c.matches(&Version::parse("2.0.0").unwrap())); - assert!(!c.matches(&Version::parse("0.9.0").unwrap())); - assert!(!c.matches(&Version::parse("2.1.0").unwrap())); - } - - // ──────────── Helper ──────────── - - fn satisfies(constraint: &str, version: &str) -> bool { - let c = VersionConstraint::parse(constraint).unwrap(); - let v = Version::parse(version).unwrap(); - c.matches(&v) - } - - // ══════════════════════════════════════════════════════════════════════════ - // 1. VERSION PARSING EDGE CASES - // ══════════════════════════════════════════════════════════════════════════ - - #[test] - fn test_parse_single_segment() { - let v = Version::parse("1").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - assert_eq!(v.build, 0); - assert_eq!(v.pre_release, None); - assert!(!v.is_dev_branch); - } - - #[test] - fn test_parse_two_segments() { - let v = Version::parse("1.2").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 0); - assert_eq!(v.build, 0); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_zero_version() { - let v = Version::parse("0.0.0").unwrap(); - assert_eq!(v.major, 0); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 0); - assert_eq!(v.build, 0); - assert_eq!(v.pre_release, None); - assert!(!v.is_dev_branch); - } - - #[test] - fn test_parse_zero_zero_one() { - let v = Version::parse("0.0.1").unwrap(); - assert_eq!((v.major, v.minor, v.patch, v.build), (0, 0, 1, 0)); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_large_version_numbers() { - let v = Version::parse("99999.1.2.3").unwrap(); - assert_eq!(v.major, 99999); - assert_eq!(v.minor, 1); - assert_eq!(v.patch, 2); - assert_eq!(v.build, 3); - } - - #[test] - fn test_parse_uppercase_v_prefix() { - let v = Version::parse("V1.2.3").unwrap(); - assert_eq!(v.major, 1); - assert_eq!(v.minor, 2); - assert_eq!(v.patch, 3); - assert_eq!(v.pre_release, None); - assert!(!v.is_dev_branch); - } - - #[test] - fn test_parse_build_metadata_stripped() { - // Build metadata after '+' should be stripped - let v = Version::parse("1.2.3+build.456").unwrap(); - assert_eq!((v.major, v.minor, v.patch), (1, 2, 3)); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_shorthand_b_normalizes_to_beta() { - // "b2" suffix → beta2 - let v = Version::parse("1.0.0-b2").unwrap(); - assert_eq!(v.pre_release, Some("beta2".to_string())); - } - - #[test] - fn test_parse_shorthand_a_normalizes_to_alpha() { - // "a1" suffix → alpha1 - let v = Version::parse("1.0.0-a1").unwrap(); - assert_eq!(v.pre_release, Some("alpha1".to_string())); - } - - #[test] - fn test_parse_shorthand_p_normalizes_to_patch() { - // "p1" suffix → patch1 - let v = Version::parse("1.0.0-p1").unwrap(); - assert_eq!(v.pre_release, Some("patch1".to_string())); - } - - #[test] - fn test_parse_shorthand_pl_normalizes_to_patch() { - // "pl2" suffix → patch2 - let v = Version::parse("1.0.0-pl2").unwrap(); - assert_eq!(v.pre_release, Some("patch2".to_string())); - } - - #[test] - fn test_parse_shorthand_rc_lowercase_normalizes_to_rc() { - // "rc2" suffix → RC2 - let v = Version::parse("1.0.0-rc2").unwrap(); - assert_eq!(v.pre_release, Some("RC2".to_string())); - } - - #[test] - fn test_parse_stability_beta_no_number() { - // "1.0.0-beta" with no number - let v = Version::parse("1.0.0-beta").unwrap(); - assert_eq!(v.pre_release, Some("beta".to_string())); - } - - #[test] - fn test_parse_dev_release_branch() { - // "dev-release-1.0" is a dev branch named "release-1.0" - let v = Version::parse("dev-release-1.0").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.dev_branch_name, Some("release-1.0".to_string())); - assert_eq!(v.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_dev_master_uppercase() { - // "DEV-master" — case-insensitive dev- prefix - let v = Version::parse("DEV-master").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.dev_branch_name, Some("master".to_string())); - } - - #[test] - fn test_parse_x_dev_two_segment() { - // "2.x-dev" → major=2, minor=0, patch=9999999, build=9999999 - let v = Version::parse("2.x-dev").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.major, 2); - assert_eq!(v.minor, 0); - assert_eq!(v.patch, 9999999); - assert_eq!(v.build, 9999999); - } - - #[test] - fn test_parse_numeric_dev_suffix() { - // "2.1-dev" — ends with -dev, treated as *-dev suffix branch - let v = Version::parse("2.1-dev").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.major, 2); - assert_eq!(v.minor, 1); - } - - #[test] - fn test_parse_stability_flag_dev() { - // "1.0.0@dev" → strip @dev suffix, parse 1.0.0 as stable - let v = Version::parse("1.0.0@dev").unwrap(); - assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); - assert!(!v.is_dev_branch); - // After stripping @dev, no pre-release suffix remains - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_stability_flag_alpha() { - let v = Version::parse("1.0.0@alpha").unwrap(); - assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_stability_flag_beta() { - let v = Version::parse("1.0.0@beta").unwrap(); - assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_stability_flag_rc() { - let v = Version::parse("1.0.0@rc").unwrap(); - assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); - assert_eq!(v.pre_release, None); - } - - #[test] - fn test_parse_inline_alias_left_side() { - // "dev-main as 1.0.x-dev" → left side is "dev-main" - let v = Version::parse("dev-main as 1.0.x-dev").unwrap(); - assert!(v.is_dev_branch); - assert_eq!(v.dev_branch_name, Some("main".to_string())); - } - - #[test] - fn test_parse_error_empty_string() { - let result = Version::parse(""); - assert!(result.is_err(), "Expected error for empty string"); - } - - #[test] - fn test_parse_error_not_a_version() { - // Strings with no numeric start should fail - let result = Version::parse("not-a-version"); - assert!( - result.is_err(), - "Expected error for 'not-a-version', got: {:?}", - result - ); - } - - #[test] - fn test_parse_error_only_dots() { - let result = Version::parse("...."); - assert!(result.is_err(), "Expected error for '....'"); - } - - #[test] - fn test_parse_error_non_numeric_segment() { - // "1.abc.3" — minor segment is non-numeric; parse degrades minor to 0 - // The implementation uses `and_then(|p| p.parse().ok()).unwrap_or(0)`, - // so non-numeric segments silently become 0. This is intentional behavior. - let v = Version::parse("1.abc.3").unwrap(); - assert_eq!(v.major, 1); - // minor "abc" fails to parse as u64, so falls back to 0 - assert_eq!(v.minor, 0); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 2. VERSION ORDERING - // ══════════════════════════════════════════════════════════════════════════ - - #[test] - fn test_ordering_equal_versions() { - let a = Version::parse("1.2.3").unwrap(); - let b = Version::parse("1.2.3").unwrap(); - assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal); - } - - #[test] - fn test_ordering_patch_difference() { - let a = Version::parse("1.2.4").unwrap(); - let b = Version::parse("1.2.3").unwrap(); - assert!(a > b); - } - - #[test] - fn test_ordering_build_segment_difference() { - let a = Version::parse("1.2.3.2").unwrap(); - let b = Version::parse("1.2.3.1").unwrap(); - assert!(a > b); - } - - #[test] - fn test_ordering_dev_branch_lt_dev_prerelease() { - // "1.0.0-dev" ends with "-dev", so the parser treats it as a *-dev suffix branch - // (is_dev_branch=true, dev_branch_name=None, major=1, minor=0, patch=9999999). - // "dev-master" is also is_dev_branch=true with dev_branch_name=Some("master"). - // When both are dev branches, they compare by dev_branch_name: - // Some("master") vs None → Some > None, so dev-master > 1.0.0-dev (x-dev form). - let dev_branch = Version::parse("dev-master").unwrap(); - let dev_prerelease = Version::parse("1.0.0-dev").unwrap(); - // Both are dev branches; "master" branch name > None → dev-master is Greater - assert!(dev_branch > dev_prerelease); - } - - #[test] - fn test_ordering_dev_prerelease_lt_alpha() { - let dev = Version::parse("1.0.0-dev").unwrap(); - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - assert!(dev < alpha); - } - - #[test] - fn test_ordering_alpha_lt_beta() { - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - let beta = Version::parse("1.0.0-beta1").unwrap(); - assert!(alpha < beta); - } - - #[test] - fn test_ordering_beta_lt_rc() { - let beta = Version::parse("1.0.0-beta1").unwrap(); - let rc = Version::parse("1.0.0-RC1").unwrap(); - assert!(beta < rc); - } - - #[test] - fn test_ordering_rc_lt_stable() { - let rc = Version::parse("1.0.0-RC1").unwrap(); - let stable = Version::parse("1.0.0").unwrap(); - assert!(rc < stable); - } - - #[test] - fn test_ordering_stable_lt_patch() { - // The Ord impl: (None, Some(_)) => Greater — stable (pre_release=None) beats any - // pre_release including "patch1". Even though stability_rank("patch")=5 which is - // higher than stable's implicit 0, that path is only reached when both sides are - // Some(_). Since stable has pre_release=None, stable > patch version. - let stable = Version::parse("1.0.0").unwrap(); - let patch = Version::parse("1.0.0-patch1").unwrap(); - assert!(stable > patch); - } - - #[test] - fn test_ordering_rc3_gt_rc2() { - let rc3 = Version::parse("1.0.0-RC3").unwrap(); - let rc2 = Version::parse("1.0.0-RC2").unwrap(); - assert!(rc3 > rc2); - } - - #[test] - fn test_ordering_alpha5_gt_alpha3() { - let a5 = Version::parse("1.0.0-alpha5").unwrap(); - let a3 = Version::parse("1.0.0-alpha3").unwrap(); - assert!(a5 > a3); - } - - #[test] - fn test_ordering_dev_branches_alphabetical() { - // Between two dev branches, compare branch names alphabetically - let dev_foo = Version::parse("dev-foo").unwrap(); - let dev_bar = Version::parse("dev-bar").unwrap(); - // "bar" < "foo" alphabetically - assert!(dev_foo > dev_bar); - } - - #[test] - fn test_ordering_zero_versions() { - let a = Version::parse("0.0.2").unwrap(); - let b = Version::parse("0.0.1").unwrap(); - assert!(a > b); - } - - #[test] - fn test_ordering_four_vs_three_segment_equal() { - // 1.2.3.0 and 1.2.3 should be equal (build defaults to 0) - let a = Version::parse("1.2.3.0").unwrap(); - let b = Version::parse("1.2.3").unwrap(); - assert_eq!(a, b); - } - - #[test] - fn test_ordering_comprehensive_chain() { - // Note: "1.0.0-dev" is parsed as a *-dev suffix branch (is_dev_branch=true, - // dev_branch_name=None) due to the "-dev" suffix rule in Version::parse. - // "dev-foo" is also a dev branch (is_dev_branch=true, dev_branch_name=Some("foo")). - // Comparing two dev branches uses dev_branch_name: None < Some("foo"), so - // the *-dev form (None) < "dev-foo" (Some("foo")). - // For "1.0.0-alpha1", "1.0.0-beta1", "1.0.0-RC1", "1.0.0": normal numeric ordering. - let dev_x_dev = Version::parse("1.0.0-dev").unwrap(); // *-dev branch, name=None - let dev_branch = Version::parse("dev-foo").unwrap(); // named branch, name=Some("foo") - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - let beta = Version::parse("1.0.0-beta1").unwrap(); - let rc = Version::parse("1.0.0-RC1").unwrap(); - let stable = Version::parse("1.0.0").unwrap(); - - // Both dev branches; dev_branch_name None < Some("foo") - assert!(dev_x_dev < dev_branch); - // dev_branch (is_dev_branch=true) < alpha (is_dev_branch=false) - assert!(dev_branch < alpha); - assert!(alpha < beta); - assert!(beta < rc); - assert!(rc < stable); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 3. CONSTRAINT PARSING EDGE CASES - // ══════════════════════════════════════════════════════════════════════════ - - // ── Caret ── - - #[test] - fn test_caret_zero_zero_three() { - // ^0.0.3 → >=0.0.3 <0.0.4 - assert!(satisfies("^0.0.3", "0.0.3")); - assert!(!satisfies("^0.0.3", "0.0.4")); - assert!(!satisfies("^0.0.3", "0.0.2")); - } - - #[test] - fn test_caret_zero_zero_zero() { - // ^0.0.0 → first non-zero is none, upper = 0.0.1 - assert!(satisfies("^0.0.0", "0.0.0")); - assert!(!satisfies("^0.0.0", "0.0.1")); - } - - #[test] - fn test_caret_single_major() { - // ^1 → >=1.0.0 <2.0.0 - assert!(satisfies("^1", "1.0.0")); - assert!(satisfies("^1", "1.99.99")); - assert!(!satisfies("^1", "2.0.0")); - assert!(!satisfies("^1", "0.9.9")); - } - - #[test] - fn test_caret_four_segments() { - // ^1.2.3.4 → >=1.2.3.4 <2.0.0.0 - assert!(satisfies("^1.2.3.4", "1.2.3.4")); - assert!(satisfies("^1.2.3.4", "1.9.0.0")); - assert!(!satisfies("^1.2.3.4", "2.0.0.0")); - assert!(!satisfies("^1.2.3.4", "1.2.3.3")); - } - - #[test] - fn test_caret_lower_boundary() { - // ^1.2.3 lower boundary: 1.2.3 matches but 1.2.2 does not - assert!(satisfies("^1.2.3", "1.2.3")); - assert!(!satisfies("^1.2.3", "1.2.2")); - } - - #[test] - fn test_caret_upper_boundary() { - // ^1.2.3 upper boundary: 1.9.9 matches, 2.0.0 does not - assert!(satisfies("^1.2.3", "1.9.9")); - assert!(!satisfies("^1.2.3", "2.0.0")); - } - - // ── Tilde ── - - #[test] - fn test_tilde_single_major() { - // ~1 → >=1.0.0 <2.0.0 - assert!(satisfies("~1", "1.0.0")); - assert!(satisfies("~1", "1.99.0")); - assert!(!satisfies("~1", "2.0.0")); - assert!(!satisfies("~1", "0.9.9")); - } - - #[test] - fn test_tilde_four_segments() { - // ~1.2.3.4 → >=1.2.3.4 <1.3.0.0 - assert!(satisfies("~1.2.3.4", "1.2.3.4")); - assert!(satisfies("~1.2.9.0", "1.2.9.0")); - assert!(!satisfies("~1.2.3.4", "1.3.0.0")); - assert!(!satisfies("~1.2.3.4", "1.2.3.3")); - } - - #[test] - fn test_tilde_lower_boundary() { - // ~1.2.3: 1.2.3 matches, 1.2.2 does not - assert!(satisfies("~1.2.3", "1.2.3")); - assert!(!satisfies("~1.2.3", "1.2.2")); - } - - #[test] - fn test_tilde_upper_boundary() { - // ~1.2.3: 1.2.9 matches, 1.3.0 does not - assert!(satisfies("~1.2.3", "1.2.9")); - assert!(!satisfies("~1.2.3", "1.3.0")); - } - - // ── Wildcard ── - - #[test] - fn test_wildcard_major_only() { - // 1.* → >=1.0.0 <2.0.0 - assert!(satisfies("1.*", "1.0.0")); - assert!(satisfies("1.*", "1.99.0")); - assert!(!satisfies("1.*", "2.0.0")); - assert!(!satisfies("1.*", "0.9.9")); - } - - #[test] - fn test_wildcard_double_star() { - // 1.*.* is treated like 1.* - assert!(satisfies("1.*.*", "1.5.0")); - assert!(!satisfies("1.*.*", "2.0.0")); - } - - #[test] - fn test_wildcard_three_segment() { - // 1.2.3.* — the implementation strips trailing .*; base is "1.2.3" - // parse_wildcard strips .* and splits on '.'; parts.len()=3 → minor constraint - assert!(satisfies("1.2.3.*", "1.2.3")); - assert!(satisfies("1.2.3.*", "1.2.9")); - assert!(!satisfies("1.2.3.*", "1.3.0")); - } - - #[test] - fn test_wildcard_zero_major() { - // 0.* → >=0.0.0 <1.0.0 - assert!(satisfies("0.*", "0.0.0")); - assert!(satisfies("0.*", "0.99.0")); - assert!(!satisfies("0.*", "1.0.0")); - } - - #[test] - fn test_wildcard_v_prefix() { - // v1.* — the wildcard parser strips the trailing .*; base becomes "v1" - // parse_wildcard's base.split('.') on "v1" → single part "v1" - // v1 fails to parse as u64, falls back to 0 — so this is like 0.* - // Mark as ignore since the behavior diverges from the expected semantic - #[allow(unused)] - let _ = VersionConstraint::parse("v1.*"); // just verify it doesn't panic - } - - // ── Hyphen ranges ── - - #[test] - fn test_hyphen_range_partial_from() { - // "1.0 - 2.0": 1.0 is a partial "from", lower = >=1.0.0 - assert!(satisfies("1.0 - 2.0", "1.0.0")); - assert!(satisfies("1.0 - 2.0", "1.5.0")); - } - - #[test] - fn test_hyphen_range_partial_to() { - // "1.0 - 2.0": upper = <=2.0.0 (inclusive) - assert!(satisfies("1.0 - 2.0", "2.0.0")); - assert!(!satisfies("1.0 - 2.0", "2.0.1")); - } - - #[test] - fn test_hyphen_range_same_version() { - // "1.0.0 - 1.0.0" → >=1.0.0 <=1.0.0, matches only 1.0.0 - assert!(satisfies("1.0.0 - 1.0.0", "1.0.0")); - assert!(!satisfies("1.0.0 - 1.0.0", "1.0.1")); - assert!(!satisfies("1.0.0 - 1.0.0", "0.9.9")); - } - - #[test] - fn test_hyphen_range_with_prerelease() { - // "1.0.0-alpha1 - 1.0.0-RC1" - assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-alpha1")); - assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-beta1")); - assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-RC1")); - assert!(!satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0")); - } - - // ── Comparison operators ── - - #[test] - fn test_gt_boundary() { - assert!(!satisfies(">1.0.0", "1.0.0")); - assert!(satisfies(">1.0.0", "1.0.1")); - } - - #[test] - fn test_lt_boundary() { - assert!(!satisfies("<1.0.0", "1.0.0")); - assert!(satisfies("<1.0.0", "0.9.9")); - } - - #[test] - fn test_lte_boundary() { - assert!(satisfies("<=1.0.0", "1.0.0")); - assert!(!satisfies("<=1.0.0", "1.0.1")); - } - - #[test] - fn test_exact_equals_sign() { - // "=1.2.3" is exact match - assert!(satisfies("=1.2.3", "1.2.3")); - assert!(!satisfies("=1.2.3", "1.2.4")); - } - - #[test] - #[ignore = "== (double equals) is not supported: the '=' handler passes '=1.2.3' to \ - Version::parse_for_constraint which fails to parse '=1' as a major number"] - fn test_double_equals_sign() { - // "==1.2.3" — the '=' branch strips one '=', leaving "=1.2.3", which is then - // passed to Version::parse_for_constraint. That function tries to parse "=1" as - // a major version number and fails. Double-equals is not a supported syntax. - assert!(satisfies("==1.2.3", "1.2.3")); - assert!(!satisfies("==1.2.3", "1.2.4")); - } - - #[test] - fn test_not_equal_boundary() { - assert!(!satisfies("!=1.5.0", "1.5.0")); - assert!(satisfies("!=1.5.0", "1.4.9")); - assert!(satisfies("!=1.5.0", "1.5.1")); - } - - #[test] - fn test_gte_with_spaces() { - // Spaces after operator should be handled - assert!(satisfies(">=1.0.0", "1.0.0")); - } - - // ── AND constraints ── - - #[test] - fn test_and_comma_separated() { - // Comma-separated constraints act as AND - assert!(satisfies(">=1.0,<2.0", "1.5.0")); - assert!(!satisfies(">=1.0,<2.0", "2.0.0")); - assert!(!satisfies(">=1.0,<2.0", "0.9.0")); - } - - #[test] - fn test_and_three_way() { - assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.3.0")); - assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "1.5.0")); - assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "2.0.0")); - } - - #[test] - fn test_and_impossible_range() { - // >=2.0 <1.0 — impossible range, nothing should match - assert!(!satisfies(">=2.0 <1.0", "1.5.0")); - assert!(!satisfies(">=2.0 <1.0", "2.0.0")); - assert!(!satisfies(">=2.0 <1.0", "0.5.0")); - } - - #[test] - fn test_and_tight_range() { - // >=1.2.3 <=1.2.3 — only exactly 1.2.3 - assert!(satisfies(">=1.2.3 <=1.2.3", "1.2.3")); - assert!(!satisfies(">=1.2.3 <=1.2.3", "1.2.4")); - assert!(!satisfies(">=1.2.3 <=1.2.3", "1.2.2")); - } - - // ── OR constraints ── - - #[test] - fn test_or_double_pipe() { - assert!(satisfies("^1.0 || ^2.0", "1.5.0")); - assert!(satisfies("^1.0 || ^2.0", "2.3.0")); - assert!(!satisfies("^1.0 || ^2.0", "3.0.0")); - } - - #[test] - fn test_or_three_branches() { - assert!(satisfies("^1.0 || ^2.0 || ^3.0", "1.0.0")); - assert!(satisfies("^1.0 || ^2.0 || ^3.0", "2.5.0")); - assert!(satisfies("^1.0 || ^2.0 || ^3.0", "3.9.9")); - assert!(!satisfies("^1.0 || ^2.0 || ^3.0", "4.0.0")); - } - - #[test] - fn test_or_with_wildcard() { - assert!(satisfies("1.* || 3.*", "1.5.0")); - assert!(satisfies("1.* || 3.*", "3.0.0")); - assert!(!satisfies("1.* || 3.*", "2.0.0")); - } - - #[test] - fn test_or_overlapping_ranges() { - // Overlapping ranges are fine — union semantics - assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "1.5.0")); - assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "2.5.0")); - assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "3.5.0")); - assert!(!satisfies(">=1.0 <3.0 || >=2.0 <4.0", "0.9.0")); - assert!(!satisfies(">=1.0 <3.0 || >=2.0 <4.0", "4.0.0")); - } - - #[test] - fn test_or_exact_versions() { - assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "1.0.0")); - assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "2.0.0")); - assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "3.0.0")); - assert!(!satisfies("1.0.0 || 2.0.0 || 3.0.0", "1.0.1")); - } - - // ── Complex combined ── - - #[test] - fn test_combined_and_within_or() { - // ">=1.0 <2.0 || >=3.0 <4.0" - assert!(satisfies(">=1.0 <2.0 || >=3.0 <4.0", "1.5.0")); - assert!(satisfies(">=1.0 <2.0 || >=3.0 <4.0", "3.5.0")); - assert!(!satisfies(">=1.0 <2.0 || >=3.0 <4.0", "2.5.0")); - assert!(!satisfies(">=1.0 <2.0 || >=3.0 <4.0", "4.0.0")); - } - - #[test] - fn test_combined_real_world_laravel_pattern() { - // "^8.0||^9.0||^10.0||^11.0" — real Laravel constraint - assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "8.5.0")); - assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "9.0.0")); - assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "10.48.22")); - assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "11.0.1")); - assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "7.9.9")); - assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "12.0.0")); - } - - #[test] - fn test_single_pipe_or() { - // Single pipe `|` is the standard Composer OR separator - assert!(satisfies("^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "6.0.0")); - assert!(satisfies("^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "9.0.0")); - assert!(satisfies("^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "11.5.0")); - assert!(!satisfies("^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "5.9.9")); - assert!(!satisfies("^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "12.0.0")); - } - - #[test] - fn test_combined_real_world_symfony_pattern() { - // ">=5.4 <7.0" — typical Symfony range - assert!(satisfies(">=5.4 <7.0", "5.4.0")); - assert!(satisfies(">=5.4 <7.0", "6.4.5")); - assert!(!satisfies(">=5.4 <7.0", "5.3.9")); - assert!(!satisfies(">=5.4 <7.0", "7.0.0")); - } - - // ── Edge cases ── - - #[test] - fn test_constraint_empty_string_is_any() { - // Empty string → Any constraint - let c = VersionConstraint::parse("*").unwrap(); - let v = Version::parse("9.9.9").unwrap(); - assert!(c.matches(&v)); - } - - #[test] - fn test_constraint_v_prefix_in_exact() { - // "v1.2.3" exact constraint — strip v prefix - assert!(satisfies("v1.2.3", "1.2.3")); - assert!(!satisfies("v1.2.3", "1.2.4")); - } - - #[test] - fn test_constraint_extra_whitespace_and() { - // Extra spaces around operators in AND groups - assert!(satisfies(">=1.0.0 <2.0.0", "1.5.0")); - assert!(!satisfies(">=1.0.0 <2.0.0", "2.0.0")); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 4. CONSTRAINT MATCHING - // ══════════════════════════════════════════════════════════════════════════ - - #[test] - fn test_dev_branch_exact_match() { - // dev-master matches dev-master constraint exactly - let c = VersionConstraint::parse("dev-master").unwrap(); - let v = Version::parse("dev-master").unwrap(); - assert!(c.matches(&v)); - } - - #[test] - fn test_dev_branch_different_branch_no_match() { - let c = VersionConstraint::parse("dev-master").unwrap(); - let v = Version::parse("dev-develop").unwrap(); - assert!(!c.matches(&v)); - } - - #[test] - fn test_dev_branch_against_caret_no_match() { - // dev-master does not satisfy ^1.0 (it is a dev branch, always lowest) - let c = VersionConstraint::parse("^1.0").unwrap(); - let v = Version::parse("dev-master").unwrap(); - assert!(!c.matches(&v)); - } - - #[test] - fn test_any_constraint_matches_dev_branch() { - // "*" matches any version including dev branches - let c = VersionConstraint::parse("*").unwrap(); - let v = Version::parse("dev-master").unwrap(); - assert!(c.matches(&v)); - } - - #[test] - fn test_prerelease_within_caret_range() { - // Pre-release of a version within ^1.0 should match - // e.g. 1.5.0-beta1 — it is >= dev boundary 1.0.0 and < dev boundary 2.0.0 - assert!(satisfies("^1.0", "1.5.0-beta1")); - } - - #[test] - fn test_caret_lower_minus_one_no_match() { - // ^1.2.3 lower-1 = 1.2.2 → should NOT match - assert!(!satisfies("^1.2.3", "1.2.2")); - } - - #[test] - fn test_caret_upper_minus_one_matches() { - // ^1.2.3 upper-1 patch: 1.9.9 should still match (below 2.0.0) - assert!(satisfies("^1.2.3", "1.9.9")); - } - - #[test] - fn test_tilde_lower_minus_one_no_match() { - assert!(!satisfies("~1.2.3", "1.2.2")); - } - - #[test] - fn test_tilde_upper_minus_one_matches() { - assert!(satisfies("~1.2.3", "1.2.9")); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 5. INTERNAL FUNCTION TESTS (via public API) - // ══════════════════════════════════════════════════════════════════════════ - - // stability_rank() — tested via ordering since the function is private - - #[test] - fn test_stability_rank_dev_via_ordering() { - // dev rank=50 (highest number = least stable), alpha rank=40 - // So dev < alpha in version ordering terms - let dev = Version::parse("1.0.0-dev").unwrap(); - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - assert!(dev < alpha, "dev should be less stable than alpha1"); - } - - #[test] - fn test_stability_rank_alpha_via_ordering() { - // alpha rank=40, beta rank=30 - let alpha = Version::parse("1.0.0-alpha1").unwrap(); - let beta = Version::parse("1.0.0-beta1").unwrap(); - assert!(alpha < beta, "alpha should be less stable than beta"); - } - - #[test] - fn test_stability_rank_beta_via_ordering() { - // beta rank=30, RC rank=20 - let beta = Version::parse("1.0.0-beta1").unwrap(); - let rc = Version::parse("1.0.0-RC1").unwrap(); - assert!(beta < rc, "beta should be less stable than RC"); - } - - #[test] - fn test_stability_rank_rc_via_ordering() { - // RC rank=20, stable rank=0 - let rc = Version::parse("1.0.0-RC1").unwrap(); - let stable = Version::parse("1.0.0").unwrap(); - assert!(rc < stable, "RC should be less stable than stable"); - } - - #[test] - fn test_stability_rank_patch_via_ordering() { - // The Ord impl: (None, Some(_)) => Greater. - // stable has pre_release=None; patch version has pre_release=Some("patch1"). - // The None arm wins unconditionally: stable is always Greater than any pre_release. - // This means "patch" releases (post-release fixes) sort BELOW stable in this impl. - let patch_ver = Version::parse("1.0.0-patch1").unwrap(); - let stable = Version::parse("1.0.0").unwrap(); - assert!( - stable > patch_ver, - "stable (None pre_release) beats patch pre-release" - ); - } - - // normalize_pre_release() — tested via Version::parse pre_release field - - #[test] - fn test_normalize_pre_release_b_to_beta() { - let v = Version::parse("1.0.0-b3").unwrap(); - assert_eq!(v.pre_release, Some("beta3".to_string())); - } - - #[test] - fn test_normalize_pre_release_a_to_alpha() { - let v = Version::parse("1.0.0-a1").unwrap(); - assert_eq!(v.pre_release, Some("alpha1".to_string())); - } - - #[test] - fn test_normalize_pre_release_rc_to_rc_uppercase() { - let v = Version::parse("1.0.0-rc").unwrap(); - assert_eq!(v.pre_release, Some("RC".to_string())); - } - - #[test] - fn test_normalize_pre_release_pl_to_patch() { - let v = Version::parse("1.0.0-pl2").unwrap(); - assert_eq!(v.pre_release, Some("patch2".to_string())); - } - - #[test] - fn test_normalize_pre_release_patch_explicit() { - let v = Version::parse("1.0.0-patch3").unwrap(); - assert_eq!(v.pre_release, Some("patch3".to_string())); - } - - // pre_release_number() — tested via ordering of numbered pre-releases - - #[test] - fn test_pre_release_number_ordering_beta() { - // beta10 > beta2 if pre_release_number extracts correctly - let b10 = Version::parse("1.0.0-beta10").unwrap(); - let b2 = Version::parse("1.0.0-beta2").unwrap(); - assert!(b10 > b2); - } - - #[test] - fn test_pre_release_number_ordering_rc() { - let rc5 = Version::parse("1.0.0-RC5").unwrap(); - let rc1 = Version::parse("1.0.0-RC1").unwrap(); - assert!(rc5 > rc1); - } - - #[test] - fn test_pre_release_number_zero_when_missing() { - // "alpha" with no number → 0; "alpha1" → 1; alpha1 > alpha - let alpha1 = Version::parse("1.0.0-alpha1").unwrap(); - let alpha = Version::parse("1.0.0-alpha").unwrap(); - assert!(alpha1 > alpha); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 6. COMPOSER BEHAVIORAL COMPATIBILITY - // ══════════════════════════════════════════════════════════════════════════ - - #[test] - fn test_composer_caret_four_matches_minor_bump() { - // ^4.0 matches 4.5.3 - assert!(satisfies("^4.0", "4.5.3")); - } - - #[test] - fn test_composer_caret_four_does_not_match_next_major() { - assert!(!satisfies("^4.0", "5.0.0")); - } - - #[test] - fn test_composer_caret_zero_three_matches_patch() { - // ^0.3 matches 0.3.5 (same minor family) - assert!(satisfies("^0.3", "0.3.5")); - } - - #[test] - fn test_composer_caret_zero_three_does_not_match_next_minor() { - // ^0.3 does NOT match 0.4.0 - assert!(!satisfies("^0.3", "0.4.0")); - } - - #[test] - fn test_composer_tilde_four_one_matches_within_major() { - // ~4.1 → >=4.1.0 <5.0.0 — matches 4.9.0 - assert!(satisfies("~4.1", "4.9.0")); - } - - #[test] - fn test_composer_tilde_four_one_does_not_match_next_major() { - // ~4.1 does NOT match 5.0.0 - assert!(!satisfies("~4.1", "5.0.0")); - } - - #[test] - fn test_composer_range_gap_matches_second_range() { - // ">=1.0 <1.1 || >=1.2" — gap at 1.1.x; 1.2.0 matches - assert!(satisfies(">=1.0 <1.1 || >=1.2", "1.2.0")); - } - - #[test] - fn test_composer_range_gap_does_not_match_in_gap() { - // 1.1.5 is in the gap — should NOT match - assert!(!satisfies(">=1.0 <1.1 || >=1.2", "1.1.5")); - } - - #[test] - fn test_composer_laravel_constraint_matches_v10() { - // "^8.0||^9.0||^10.0||^11.0" — Laravel-style; 10.48.22 matches - assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "10.48.22")); - } - - #[test] - fn test_composer_laravel_constraint_does_not_match_v7() { - assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "7.9.9")); - } - - #[test] - fn test_composer_symfony_range_matches_6_4() { - // ">=5.4 <7.0" — Symfony; 6.4.5 matches - assert!(satisfies(">=5.4 <7.0", "6.4.5")); - } - - #[test] - fn test_composer_symfony_range_does_not_match_7_0() { - assert!(!satisfies(">=5.4 <7.0", "7.0.0")); - } - - #[test] - fn test_composer_not_equal_in_range() { - // ">=1.0 !=1.5.0 <2.0" — typical blacklist constraint - assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.4.9")); - assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "1.5.0")); - assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.5.1")); - assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "2.0.0")); - } - - #[test] - fn test_composer_exact_major_minor_match() { - // exact "1.5.0" only matches 1.5.0 - assert!(satisfies("1.5.0", "1.5.0")); - assert!(!satisfies("1.5.0", "1.5.1")); - } - - // ══════════════════════════════════════════════════════════════════════════ - // 7. DIVERGENCE INVESTIGATION - // ══════════════════════════════════════════════════════════════════════════ - - #[test] - fn test_hyphen_range_partial_upper_two_segment() { - // "1.0 - 2": upper is <=2.0.0 (parse "2" → 2.0.0.0, inclusive) - assert!(satisfies("1.0 - 2", "2.0.0")); - assert!(!satisfies("1.0 - 2", "2.0.1")); - assert!(!satisfies("1.0 - 2", "2.1.0")); - } - - #[test] - fn test_caret_with_prerelease_suffix() { - // ^1.2.3-beta1 — the caret parser ignores pre-release in its bounds calculation - // because parse_caret works on the numeric parts only. - // Lower: dev_boundary(1,2,3,0). Upper: dev_boundary(2,0,0,0). - // 1.2.3-beta1 (pre_release=Some("beta1")) is >= lower boundary? - // dev_boundary uses pre_release=Some("dev"), so lower is (1,2,3,0,dev) - // Version 1.2.3-beta1 has same numeric, but beta > dev in stability terms - // so 1.2.3-beta1 >= lower (1.2.3-dev) is true. - assert!(satisfies("^1.2.3-beta1", "1.2.3-beta1")); - assert!(satisfies("^1.2.3-beta1", "1.5.0")); - assert!(!satisfies("^1.2.3-beta1", "2.0.0")); - } - - #[test] - fn test_tilde_with_prerelease_suffix() { - // ~1.2.3-alpha1: lower = dev_boundary(1,2,3,0), upper = dev_boundary(1,3,0,0) - // 1.2.3-alpha1 has numeric (1,2,3,0); pre_release "alpha1" > "dev" - assert!(satisfies("~1.2.3-alpha1", "1.2.3-alpha1")); - assert!(satisfies("~1.2.3-alpha1", "1.2.9")); - assert!(!satisfies("~1.2.3-alpha1", "1.3.0")); - } - - #[test] - fn test_dev_boundary_comparison() { - // Version::dev_boundary creates a version with pre_release=Some("dev") and - // is_dev_branch=false. These should sort correctly against real versions. - let lower = Version::dev_boundary(1, 0, 0, 0); - let v = Version::parse("1.0.0").unwrap(); - // 1.0.0 (stable) > 1.0.0-dev (lower boundary) - assert!(v > lower); - } - - #[test] - fn test_x_dev_ordering_within_range() { - // "2.x-dev" version has patch=9999999, build=9999999 and is a dev branch. - // Dev branches are always lowest. So "2.x-dev" < "2.0.0" < "3.0.0". - let x_dev = Version::parse("2.x-dev").unwrap(); - let stable = Version::parse("2.0.0").unwrap(); - assert!(x_dev < stable); - } - - #[test] - fn test_four_segment_vs_three_segment_constraint() { - // "1.2.3.4" exact constraint — matches only 1.2.3.4, not 1.2.3 - assert!(satisfies("1.2.3.4", "1.2.3.4")); - assert!(!satisfies("1.2.3.4", "1.2.3")); - assert!(!satisfies("1.2.3.4", "1.2.3.5")); - } - - #[test] - fn test_date_style_version_ordering() { - // Date-based versioning: 20230101 > 20220101 - let a = Version::parse("20230101.0.0").unwrap(); - let b = Version::parse("20220101.0.0").unwrap(); - assert!(a > b); - } -} diff --git a/crates/mozart-core/Cargo.toml b/crates/mozart-core/Cargo.toml index 885fdf4..599dbb3 100644 --- a/crates/mozart-core/Cargo.toml +++ b/crates/mozart-core/Cargo.toml @@ -4,10 +4,10 @@ version.workspace = true edition.workspace = true [dependencies] +mozart-spdx-licenses.workspace = true anyhow.workspace = true colored.workspace = true dialoguer.workspace = true -mozart-spdx-licenses.workspace = true regex.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml index 8672708..d0159b6 100644 --- a/crates/mozart-registry/Cargo.toml +++ b/crates/mozart-registry/Cargo.toml @@ -4,9 +4,9 @@ version.workspace = true edition.workspace = true [dependencies] -mozart-constraint.workspace = true mozart-core.workspace = true mozart-metadata-minifier.workspace = true +mozart-semver.workspace = true anyhow.workspace = true filetime.workspace = true flate2.workspace = true diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs index 61895eb..e240dcd 100644 --- a/crates/mozart-registry/src/resolver.rs +++ b/crates/mozart-registry/src/resolver.rs @@ -15,8 +15,8 @@ use pubgrub::{ use crate::cache::Cache; use crate::packagist; -use mozart_constraint::{Constraint, VersionConstraint}; use mozart_core::package::Stability; +use mozart_semver::{Constraint, VersionConstraint}; // ───────────────────────────────────────────────────────────────────────────── // Stability constants @@ -372,7 +372,7 @@ fn single_constraint_to_ranges(c: &Constraint) -> Result { } /// Convert a `constraint::Version` to a `ComposerVersion`. -fn version_to_composer(v: &mozart_constraint::Version) -> Result { +fn version_to_composer(v: &mozart_semver::Version) -> Result { // Dev branches cannot be represented as ComposerVersion if v.is_dev_branch { return Err(format!( diff --git a/crates/mozart-semver/Cargo.toml b/crates/mozart-semver/Cargo.toml new file mode 100644 index 0000000..cee0804 --- /dev/null +++ b/crates/mozart-semver/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "mozart-semver" +version.workspace = true +edition.workspace = true diff --git a/crates/mozart-semver/src/lib.rs b/crates/mozart-semver/src/lib.rs new file mode 100644 index 0000000..7c417ec --- /dev/null +++ b/crates/mozart-semver/src/lib.rs @@ -0,0 +1,1987 @@ +use std::cmp::Ordering; + +/// A parsed Composer version (always 4 numeric segments + optional stability suffix). +/// Composer normalizes all versions to `major.minor.patch.build[-stability[N]]`. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Version { + pub major: u64, + pub minor: u64, + pub patch: u64, + pub build: u64, + /// None = stable, Some("alpha1"), Some("beta2"), Some("RC1"), Some("dev") + pub pre_release: Option, + /// true for "dev-master", "dev-feature/foo", etc. + pub is_dev_branch: bool, + /// The original branch name for dev branches (e.g. "master", "feature/foo") + pub dev_branch_name: Option, +} + +/// Stability rank for ordering (lower = more stable). +fn stability_rank(pre: &str) -> u8 { + let lower = pre.to_lowercase(); + if lower.starts_with("dev") { + 50 + } else if lower.starts_with("alpha") || lower.starts_with("a") { + 40 + } else if lower.starts_with("beta") || lower.starts_with("b") { + 30 + } else if lower.starts_with("rc") { + 20 + } else if lower.starts_with("patch") || lower.starts_with("pl") || lower == "p" { + 5 + } else { + 0 + } +} + +/// Extract numeric suffix from a pre-release string like "alpha1" → 1, "beta" → 0 +fn pre_release_number(pre: &str) -> u64 { + let digits: String = pre.chars().skip_while(|c| c.is_alphabetic()).collect(); + digits.parse().unwrap_or(0) +} + +impl PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + // Dev branches are always lowest + match (self.is_dev_branch, other.is_dev_branch) { + (true, true) => { + // Compare branch names + return self.dev_branch_name.cmp(&other.dev_branch_name); + } + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + (false, false) => {} + } + + // Compare numeric segments + let num_cmp = (self.major, self.minor, self.patch, self.build).cmp(&( + other.major, + other.minor, + other.patch, + other.build, + )); + if num_cmp != Ordering::Equal { + return num_cmp; + } + + // Compare pre-release: None (stable) > any pre-release + match (&self.pre_release, &other.pre_release) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => { + let rank_a = stability_rank(a); + let rank_b = stability_rank(b); + match rank_a.cmp(&rank_b) { + Ordering::Equal => { + // Same stability: compare numeric suffix + pre_release_number(a).cmp(&pre_release_number(b)) + } + // Lower rank = more stable = greater version + Ordering::Less => Ordering::Greater, + Ordering::Greater => Ordering::Less, + } + } + } + } +} + +impl Version { + /// Parse a version string into a `Version` struct using Composer normalization rules. + /// + /// For inline aliases (`"1.0.x-dev as 1.0.0"`), the LEFT side (the real branch version) + /// is used. This is the correct behaviour for identifying *what* version a package provides. + pub fn parse(input: &str) -> Result { + let s = input.trim(); + + // Strip inline alias: "1.0.x-dev as 1.0.0" → "1.0.x-dev" + let s = if let Some(pos) = s.find(" as ") { + &s[..pos] + } else { + s + }; + + // Strip stability flag: "@dev", "@alpha", "@beta", "@RC", "@stable" + let s = if let Some(pos) = s.rfind('@') { + let after = &s[pos + 1..]; + let known = ["dev", "alpha", "beta", "rc", "stable"]; + if known.iter().any(|k| after.eq_ignore_ascii_case(k)) { + &s[..pos] + } else { + s + } + } else { + s + }; + + // Handle dev-* prefix branches + if s.to_lowercase().starts_with("dev-") { + let branch = &s[4..]; + return Ok(Version { + major: 0, + minor: 0, + patch: 0, + build: 0, + pre_release: Some("dev".to_string()), + is_dev_branch: true, + dev_branch_name: Some(branch.to_string()), + }); + } + + // Handle *-dev suffix (e.g., "2.1.x-dev" or "2.x-dev") + let s_lower = s.to_lowercase(); + if s_lower.ends_with("-dev") || s_lower.ends_with(".x-dev") { + let base = if s_lower.ends_with("-dev") { + &s[..s.len() - 4] + } else { + s + }; + // Replace any trailing .x with nothing, parse numeric parts + let base = base.trim_end_matches(".x").trim_end_matches("-dev"); + let parts: Vec<&str> = base.split('.').collect(); + let major = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + return Ok(Version { + major, + minor, + patch: 9999999, + build: 9999999, + pre_release: Some("dev".to_string()), + is_dev_branch: true, + dev_branch_name: None, + }); + } + + // Strip leading v/V + let s = s + .strip_prefix('v') + .or_else(|| s.strip_prefix('V')) + .unwrap_or(s); + + // Strip build metadata after + + let s = s.split('+').next().unwrap_or(s); + + // Parse the version using regex-like approach + parse_classical_version(s) + } + + /// Parse a version string for use inside a *constraint expression*. + /// + /// The difference from [`Version::parse`] is the treatment of inline aliases: + /// `"1.0.x-dev as 1.0.0"` → takes the **right** side (`1.0.0`). + /// + /// Inline aliases appear in `require` fields like: + /// ```text + /// "some/package": "1.0.x-dev as 1.0.0" + /// ``` + /// Here the author wants the constraint to be satisfied by the real version `1.0.0`, + /// while the left side (`1.0.x-dev`) indicates the branch that provides it. + pub fn parse_for_constraint(input: &str) -> Result { + let s = input.trim(); + // For inline aliases, take the RIGHT side (alias target) + let s = if let Some(pos) = s.find(" as ") { + s[pos + 4..].trim() + } else { + s + }; + Version::parse(s) + } + + /// Create a "dev boundary" version for constraint matching (major.minor.patch.build with dev pre-release). + pub fn dev_boundary(major: u64, minor: u64, patch: u64, build: u64) -> Version { + Version { + major, + minor, + patch, + build, + pre_release: Some("dev".to_string()), + is_dev_branch: false, + dev_branch_name: None, + } + } +} + +fn parse_classical_version(s: &str) -> Result { + // Split on '-' to separate version from pre-release + let (version_part, pre_part) = if let Some(pos) = s.find('-') { + (&s[..pos], Some(&s[pos + 1..])) + } else { + (s, None) + }; + + let segments: Vec<&str> = version_part.split('.').collect(); + if segments.is_empty() || segments[0].is_empty() { + return Err(format!("Invalid version: {s}")); + } + + let major: u64 = segments[0] + .parse() + .map_err(|_| format!("Invalid major version segment: {}", segments[0]))?; + let minor: u64 = segments.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u64 = segments + .get(2) + .and_then(|p| { + // strip trailing .x + let p = p.trim_end_matches('x').trim_end_matches('.'); + if p.is_empty() { + Some(0) + } else { + p.parse().ok() + } + }) + .unwrap_or(0); + let build: u64 = segments.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + + let pre_release = pre_part.map(normalize_pre_release); + + Ok(Version { + major, + minor, + patch, + build, + pre_release, + is_dev_branch: false, + dev_branch_name: None, + }) +} + +fn normalize_pre_release(s: &str) -> String { + // Normalize aliases: b→beta, a→alpha, rc→RC, p/pl/patch→patch + let lower = s.to_lowercase(); + // Strip leading non-alpha characters (dots, underscores, dashes used as separators) + let normalized = lower + .trim_start_matches(|c: char| !c.is_alphabetic()) + .to_string(); + + // Extract the alphabetic prefix (stability name) + let alpha: String = normalized + .chars() + .take_while(|c| c.is_alphabetic()) + .collect(); + // Extract only digits from the rest (strip separators like dots) + let num: String = normalized + .chars() + .skip_while(|c| c.is_alphabetic()) + .filter(|c| c.is_ascii_digit()) + .collect(); + + if alpha.starts_with("beta") || alpha == "b" { + format!("beta{num}") + } else if alpha.starts_with("alpha") || alpha == "a" { + format!("alpha{num}") + } else if alpha == "rc" { + format!("RC{num}") + } else if alpha == "patch" || alpha == "pl" || alpha == "p" { + format!("patch{num}") + } else if alpha == "dev" { + "dev".to_string() + } else { + s.to_string() + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Constraint types +// ───────────────────────────────────────────────────────────────────────────── + +/// A single atomic constraint. +#[derive(Debug, Clone)] +pub enum Constraint { + /// Exact version match + Exact(Version), + /// Greater than: `> 1.2.3` + GreaterThan(Version), + /// Greater than or equal: `>= 1.2.3` + GreaterThanOrEqual(Version), + /// Less than: `< 1.2.3` + LessThan(Version), + /// Less than or equal: `<= 1.2.3` + LessThanOrEqual(Version), + /// Not equal: `!= 1.2.3` + NotEqual(Version), + /// Matches any version + Any, +} + +impl Constraint { + pub fn matches(&self, v: &Version) -> bool { + match self { + Constraint::Exact(target) => v == target, + Constraint::GreaterThan(target) => v > target, + Constraint::GreaterThanOrEqual(target) => v >= target, + Constraint::LessThan(target) => v < target, + Constraint::LessThanOrEqual(target) => v <= target, + Constraint::NotEqual(target) => v != target, + Constraint::Any => true, + } + } +} + +/// A compound constraint with AND/OR combinators. +#[derive(Debug, Clone)] +pub enum VersionConstraint { + /// Single atomic constraint + Single(Constraint), + /// All must match (AND — space/comma separated) + And(Vec), + /// At least one must match (OR — `||` separated) + Or(Vec), +} + +impl VersionConstraint { + pub fn matches(&self, version: &Version) -> bool { + match self { + VersionConstraint::Single(c) => c.matches(version), + VersionConstraint::And(cs) => cs.iter().all(|c| c.matches(version)), + VersionConstraint::Or(cs) => cs.iter().any(|c| c.matches(version)), + } + } + + /// Parse a constraint string like `^1.2`, `>=1.0 <2.0`, `^1.0 || ^2.0`. + pub fn parse(input: &str) -> Result { + let input = input.trim(); + + // Split on || (OR) + let or_parts: Vec<&str> = split_or(input); + + if or_parts.len() > 1 { + let constraints: Result, _> = + or_parts.iter().map(|p| parse_and_group(p.trim())).collect(); + let mut cs = constraints?; + // Flatten single-element groups + if cs.len() == 1 { + return Ok(cs.remove(0)); + } + return Ok(VersionConstraint::Or(cs)); + } + + parse_and_group(input) + } +} + +/// Split on `|` or `||` (pipe-OR). Composer accepts both forms. +fn split_or(s: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'|' { + parts.push(s[start..i].trim()); + i += 1; + // Skip second pipe if `||` + if i < bytes.len() && bytes[i] == b'|' { + i += 1; + } + start = i; + } else { + i += 1; + } + } + parts.push(s[start..].trim()); + // Filter out empty parts (e.g. from leading/trailing pipes) + parts.into_iter().filter(|p| !p.is_empty()).collect() +} + +/// Parse an AND group (space or comma separated constraints). +fn parse_and_group(s: &str) -> Result { + // Detect inline alias first: "1.0.x-dev as 1.0.0" + // The entire expression is a single atomic constraint; parse it directly. + if s.contains(" as ") { + return parse_single(s); + } + + // Detect hyphen range first: "1.0 - 2.0" where both sides start with a digit + if let Some(idx) = s.find(" - ") { + let before = s[..idx].trim(); + let after = s[idx + 3..].trim(); + let before_is_version = before + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit() || c == 'v' || c == 'V'); + let after_is_version = after + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit() || c == 'v' || c == 'V'); + if before_is_version && after_is_version { + return parse_hyphen_range(s); + } + } + + let parts = split_and(s); + + if parts.is_empty() { + return Err("Empty constraint".to_string()); + } + + let constraints: Result, _> = parts.iter().map(|p| parse_single(p.trim())).collect(); + let mut cs = constraints?; + + if cs.len() == 1 { + return Ok(cs.remove(0)); + } + + // Flatten nested And + let flat: Vec = cs + .into_iter() + .flat_map(|c| match c { + VersionConstraint::And(inner) => inner, + other => vec![other], + }) + .collect(); + + Ok(VersionConstraint::And(flat)) +} + +/// Split on spaces or commas (AND separator), respecting that version strings +/// can contain `-` (pre-release). +fn split_and(s: &str) -> Vec { + // A constraint "part" is separated by space or comma when not part of + // operator prefixes like `>=`, `<=`, `!=`, or version like `1.2.3-beta`. + // Strategy: tokenize by whitespace/comma, then re-join multi-token ranges. + let tokens: Vec<&str> = s.split([' ', ',']).filter(|t| !t.is_empty()).collect(); + + let mut parts: Vec = Vec::new(); + let mut current = String::new(); + + for token in tokens { + if current.is_empty() { + current = token.to_string(); + } else { + // If the token starts with an operator or a digit/^ ~/>, it's a new constraint + let starts_new = token.starts_with(|c: char| { + matches!(c, '>' | '<' | '!' | '=' | '^' | '~' | '*') || c.is_ascii_digit() + }); + if starts_new { + parts.push(current.trim().to_string()); + current = token.to_string(); + } else { + // Continuation (e.g. part of a version string with spaces) + current.push(' '); + current.push_str(token); + } + } + } + if !current.is_empty() { + parts.push(current.trim().to_string()); + } + + parts +} + +/// Parse a single constraint part. +fn parse_single(s: &str) -> Result { + if s == "*" || s.is_empty() { + return Ok(VersionConstraint::Single(Constraint::Any)); + } + + // Caret: ^1.2.3 + if let Some(rest) = s.strip_prefix('^') { + return parse_caret(rest); + } + + // Tilde: ~1.2.3 + if let Some(rest) = s.strip_prefix('~') { + return parse_tilde(rest); + } + + // Hyphen range: "1.0 - 2.0" — handled at and-group level, but check here too + if s.contains(" - ") { + return parse_hyphen_range(s); + } + + // Comparison operators + // Use parse_for_constraint so that inline aliases like "1.0.x-dev as 1.0.0" + // resolve to the alias target (right-hand side) when used in constraint context. + if let Some(rest) = s.strip_prefix(">=") { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::GreaterThanOrEqual(v))); + } + if let Some(rest) = s.strip_prefix("<=") { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::LessThanOrEqual(v))); + } + if let Some(rest) = s.strip_prefix("!=") { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::NotEqual(v))); + } + if let Some(rest) = s.strip_prefix('>') { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::GreaterThan(v))); + } + if let Some(rest) = s.strip_prefix('<') { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::LessThan(v))); + } + if let Some(rest) = s.strip_prefix('=') { + let v = Version::parse_for_constraint(rest.trim())?; + return Ok(VersionConstraint::Single(Constraint::Exact(v))); + } + + // Wildcard: 1.2.* or 1.* + if s.ends_with(".*") || s.ends_with(".*.*") || s == "*" { + return parse_wildcard(s); + } + + // Exact version (may carry an inline alias; take the alias target for matching) + let v = Version::parse_for_constraint(s)?; + Ok(VersionConstraint::Single(Constraint::Exact(v))) +} + +/// Parse `^major.minor.patch` caret constraint. +/// First non-zero segment is the "locked" boundary. +fn parse_caret(s: &str) -> Result { + let parts: Vec<&str> = s.split('.').collect(); + let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); + let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + + let lower = Version::dev_boundary(major, minor, patch, build); + + // Determine upper bound based on first non-zero segment + let upper = if major > 0 { + Version::dev_boundary(major + 1, 0, 0, 0) + } else if minor > 0 { + Version::dev_boundary(0, minor + 1, 0, 0) + } else if patch > 0 { + Version::dev_boundary(0, 0, patch + 1, 0) + } else { + Version::dev_boundary(0, 0, 1, 0) + }; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), + VersionConstraint::Single(Constraint::LessThan(upper)), + ])) +} + +/// Parse `~major.minor.patch` tilde constraint. +fn parse_tilde(s: &str) -> Result { + let parts: Vec<&str> = s.split('.').collect(); + let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); + let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); + + let lower = Version::dev_boundary(major, minor, patch, build); + + // ~major.minor.patch → >=major.minor.patch =major.minor.0 <(major+1).0.0 + // ~major → >=major.0.0 <(major+1).0.0 + let upper = if parts.len() >= 3 { + Version::dev_boundary(major, minor + 1, 0, 0) + } else { + Version::dev_boundary(major + 1, 0, 0, 0) + }; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), + VersionConstraint::Single(Constraint::LessThan(upper)), + ])) +} + +/// Parse `1.2.*` wildcard constraint. +fn parse_wildcard(s: &str) -> Result { + if s == "*" { + return Ok(VersionConstraint::Single(Constraint::Any)); + } + + // Strip trailing .* + let base = s.trim_end_matches(".*"); + if base.is_empty() { + return Ok(VersionConstraint::Single(Constraint::Any)); + } + + let parts: Vec<&str> = base.split('.').collect(); + let major: u64 = parts.first().and_then(|p| p.parse().ok()).unwrap_or(0); + let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); + + let (lower, upper) = if parts.len() == 1 { + ( + Version::dev_boundary(major, 0, 0, 0), + Version::dev_boundary(major + 1, 0, 0, 0), + ) + } else { + ( + Version::dev_boundary(major, minor, 0, 0), + Version::dev_boundary(major, minor + 1, 0, 0), + ) + }; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower)), + VersionConstraint::Single(Constraint::LessThan(upper)), + ])) +} + +/// Parse `1.0 - 2.0` hyphen range. +fn parse_hyphen_range(s: &str) -> Result { + let parts: Vec<&str> = s.splitn(2, " - ").collect(); + if parts.len() != 2 { + return Err(format!("Invalid hyphen range: {s}")); + } + + let lower_v = Version::parse_for_constraint(parts[0].trim())?; + let upper_v = Version::parse_for_constraint(parts[1].trim())?; + + Ok(VersionConstraint::And(vec![ + VersionConstraint::Single(Constraint::GreaterThanOrEqual(lower_v)), + VersionConstraint::Single(Constraint::LessThanOrEqual(upper_v)), + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ──────────── Version parsing ──────────── + + #[test] + fn test_parse_simple() { + let v = Version::parse("1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_parse_with_v_prefix() { + let v = Version::parse("v1.2").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 0); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_four_segments() { + let v = Version::parse("1.2.3.4").unwrap(); + assert_eq!((v.major, v.minor, v.patch, v.build), (1, 2, 3, 4)); + } + + #[test] + fn test_parse_beta() { + let v = Version::parse("1.0.0-beta.1").unwrap(); + assert_eq!(v.major, 1); + // "beta.1" normalizes to "beta1" (dot is stripped) + assert_eq!(v.pre_release, Some("beta1".to_string())); + } + + #[test] + fn test_parse_beta1() { + let v = Version::parse("1.0.0-beta1").unwrap(); + assert_eq!(v.pre_release, Some("beta1".to_string())); + } + + #[test] + fn test_parse_rc() { + let v = Version::parse("1.0.0-RC1").unwrap(); + assert_eq!(v.pre_release, Some("RC1".to_string())); + } + + #[test] + fn test_parse_alpha() { + let v = Version::parse("2.0.0-alpha3").unwrap(); + assert_eq!(v.pre_release, Some("alpha3".to_string())); + } + + #[test] + fn test_parse_dev_master() { + let v = Version::parse("dev-master").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("master".to_string())); + assert_eq!(v.pre_release, Some("dev".to_string())); + } + + #[test] + fn test_parse_dev_feature() { + let v = Version::parse("dev-feature/foo").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("feature/foo".to_string())); + } + + #[test] + fn test_parse_x_dev() { + let v = Version::parse("2.1.x-dev").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.major, 2); + assert_eq!(v.minor, 1); + assert_eq!(v.patch, 9999999); + assert_eq!(v.build, 9999999); + } + + #[test] + fn test_parse_strip_at_stability() { + let v = Version::parse("1.2.3@stable").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_inline_alias() { + let v = Version::parse("1.0.x-dev as 1.0.0").unwrap(); + // Takes left side: 1.0.x-dev + assert!(v.is_dev_branch); + } + + #[test] + fn test_parse_for_constraint_inline_alias() { + // parse_for_constraint takes the RIGHT side of an inline alias + let v = Version::parse_for_constraint("1.0.x-dev as 1.0.0").unwrap(); + assert!(!v.is_dev_branch); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 0); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_for_constraint_no_alias() { + // Without an alias, parse_for_constraint behaves like parse + let v = Version::parse_for_constraint("1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_constraint_inline_alias_exact_matches_target() { + // A constraint written as "1.0.x-dev as 1.0.0" should match 1.0.0 (the alias target) + let c = VersionConstraint::parse("1.0.x-dev as 1.0.0").unwrap(); + let target = Version::parse("1.0.0").unwrap(); + assert!(c.matches(&target)); + // But NOT a different version + let other = Version::parse("1.1.0").unwrap(); + assert!(!c.matches(&other)); + } + + // ──────────── Version ordering ──────────── + + #[test] + fn test_ordering_major() { + let a = Version::parse("2.0.0").unwrap(); + let b = Version::parse("1.0.0").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_minor() { + let a = Version::parse("1.2.0").unwrap(); + let b = Version::parse("1.1.0").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_stable_gt_rc() { + let stable = Version::parse("1.0.0").unwrap(); + let rc = Version::parse("1.0.0-RC1").unwrap(); + assert!(stable > rc); + } + + #[test] + fn test_ordering_rc_gt_beta() { + let rc = Version::parse("1.0.0-RC1").unwrap(); + let beta = Version::parse("1.0.0-beta1").unwrap(); + assert!(rc > beta); + } + + #[test] + fn test_ordering_beta_gt_alpha() { + let beta = Version::parse("1.0.0-beta1").unwrap(); + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + assert!(beta > alpha); + } + + #[test] + fn test_ordering_alpha_gt_dev_branch() { + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + let dev = Version::parse("dev-master").unwrap(); + assert!(alpha > dev); + } + + #[test] + fn test_ordering_pre_release_numbers() { + let beta2 = Version::parse("1.0.0-beta2").unwrap(); + let beta1 = Version::parse("1.0.0-beta1").unwrap(); + assert!(beta2 > beta1); + } + + // ──────────── Constraint parsing ──────────── + + #[test] + fn test_parse_any() { + let c = VersionConstraint::parse("*").unwrap(); + let v = Version::parse("1.2.3").unwrap(); + assert!(c.matches(&v)); + } + + #[test] + fn test_parse_exact() { + let c = VersionConstraint::parse("1.2.3").unwrap(); + let v = Version::parse("1.2.3").unwrap(); + assert!(c.matches(&v)); + let v2 = Version::parse("1.2.4").unwrap(); + assert!(!c.matches(&v2)); + } + + #[test] + fn test_parse_gte() { + let c = VersionConstraint::parse(">=1.0.0").unwrap(); + assert!(c.matches(&Version::parse("1.0.0").unwrap())); + assert!(c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("0.9.0").unwrap())); + } + + #[test] + fn test_parse_caret_major() { + let c = VersionConstraint::parse("^1.2").unwrap(); + assert!(c.matches(&Version::parse("1.2.0").unwrap())); + assert!(c.matches(&Version::parse("1.3.0").unwrap())); + assert!(c.matches(&Version::parse("1.9.9").unwrap())); + assert!(!c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("1.1.0").unwrap())); + } + + #[test] + fn test_parse_caret_zero_minor() { + // ^0.2.3 → >=0.2.3 <0.3.0 + let c = VersionConstraint::parse("^0.2.3").unwrap(); + assert!(c.matches(&Version::parse("0.2.3").unwrap())); + assert!(c.matches(&Version::parse("0.2.9").unwrap())); + assert!(!c.matches(&Version::parse("0.3.0").unwrap())); + assert!(!c.matches(&Version::parse("1.0.0").unwrap())); + } + + #[test] + fn test_parse_tilde_three_parts() { + // ~1.2.3 → >=1.2.3 <1.3.0 + let c = VersionConstraint::parse("~1.2.3").unwrap(); + assert!(c.matches(&Version::parse("1.2.3").unwrap())); + assert!(c.matches(&Version::parse("1.2.9").unwrap())); + assert!(!c.matches(&Version::parse("1.3.0").unwrap())); + } + + #[test] + fn test_parse_tilde_two_parts() { + // ~1.2 → >=1.2.0 <2.0.0 + let c = VersionConstraint::parse("~1.2").unwrap(); + assert!(c.matches(&Version::parse("1.2.0").unwrap())); + assert!(c.matches(&Version::parse("1.9.0").unwrap())); + assert!(!c.matches(&Version::parse("2.0.0").unwrap())); + } + + #[test] + fn test_parse_wildcard() { + let c = VersionConstraint::parse("1.2.*").unwrap(); + assert!(c.matches(&Version::parse("1.2.0").unwrap())); + assert!(c.matches(&Version::parse("1.2.9").unwrap())); + assert!(!c.matches(&Version::parse("1.3.0").unwrap())); + } + + #[test] + fn test_parse_and() { + let c = VersionConstraint::parse(">=1.0 <2.0").unwrap(); + assert!(c.matches(&Version::parse("1.0.0").unwrap())); + assert!(c.matches(&Version::parse("1.9.9").unwrap())); + assert!(!c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("0.9.9").unwrap())); + } + + #[test] + fn test_parse_or() { + let c = VersionConstraint::parse("^1.0 || ^2.0").unwrap(); + assert!(c.matches(&Version::parse("1.5.0").unwrap())); + assert!(c.matches(&Version::parse("2.3.0").unwrap())); + assert!(!c.matches(&Version::parse("3.0.0").unwrap())); + } + + #[test] + fn test_parse_not_equal() { + let c = VersionConstraint::parse("!=1.5.0").unwrap(); + assert!(c.matches(&Version::parse("1.4.0").unwrap())); + assert!(!c.matches(&Version::parse("1.5.0").unwrap())); + assert!(c.matches(&Version::parse("1.6.0").unwrap())); + } + + #[test] + fn test_parse_hyphen_range() { + let c = VersionConstraint::parse("1.0 - 2.0").unwrap(); + assert!(c.matches(&Version::parse("1.0.0").unwrap())); + assert!(c.matches(&Version::parse("1.5.0").unwrap())); + assert!(c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("0.9.0").unwrap())); + assert!(!c.matches(&Version::parse("2.1.0").unwrap())); + } + + // ──────────── Helper ──────────── + + fn satisfies(constraint: &str, version: &str) -> bool { + let c = VersionConstraint::parse(constraint).unwrap(); + let v = Version::parse(version).unwrap(); + c.matches(&v) + } + + // ══════════════════════════════════════════════════════════════════════════ + // 1. VERSION PARSING EDGE CASES + // ══════════════════════════════════════════════════════════════════════════ + + #[test] + fn test_parse_single_segment() { + let v = Version::parse("1").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 0); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_parse_two_segments() { + let v = Version::parse("1.2").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 0); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_zero_version() { + let v = Version::parse("0.0.0").unwrap(); + assert_eq!(v.major, 0); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 0); + assert_eq!(v.build, 0); + assert_eq!(v.pre_release, None); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_parse_zero_zero_one() { + let v = Version::parse("0.0.1").unwrap(); + assert_eq!((v.major, v.minor, v.patch, v.build), (0, 0, 1, 0)); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_large_version_numbers() { + let v = Version::parse("99999.1.2.3").unwrap(); + assert_eq!(v.major, 99999); + assert_eq!(v.minor, 1); + assert_eq!(v.patch, 2); + assert_eq!(v.build, 3); + } + + #[test] + fn test_parse_uppercase_v_prefix() { + let v = Version::parse("V1.2.3").unwrap(); + assert_eq!(v.major, 1); + assert_eq!(v.minor, 2); + assert_eq!(v.patch, 3); + assert_eq!(v.pre_release, None); + assert!(!v.is_dev_branch); + } + + #[test] + fn test_parse_build_metadata_stripped() { + // Build metadata after '+' should be stripped + let v = Version::parse("1.2.3+build.456").unwrap(); + assert_eq!((v.major, v.minor, v.patch), (1, 2, 3)); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_shorthand_b_normalizes_to_beta() { + // "b2" suffix → beta2 + let v = Version::parse("1.0.0-b2").unwrap(); + assert_eq!(v.pre_release, Some("beta2".to_string())); + } + + #[test] + fn test_parse_shorthand_a_normalizes_to_alpha() { + // "a1" suffix → alpha1 + let v = Version::parse("1.0.0-a1").unwrap(); + assert_eq!(v.pre_release, Some("alpha1".to_string())); + } + + #[test] + fn test_parse_shorthand_p_normalizes_to_patch() { + // "p1" suffix → patch1 + let v = Version::parse("1.0.0-p1").unwrap(); + assert_eq!(v.pre_release, Some("patch1".to_string())); + } + + #[test] + fn test_parse_shorthand_pl_normalizes_to_patch() { + // "pl2" suffix → patch2 + let v = Version::parse("1.0.0-pl2").unwrap(); + assert_eq!(v.pre_release, Some("patch2".to_string())); + } + + #[test] + fn test_parse_shorthand_rc_lowercase_normalizes_to_rc() { + // "rc2" suffix → RC2 + let v = Version::parse("1.0.0-rc2").unwrap(); + assert_eq!(v.pre_release, Some("RC2".to_string())); + } + + #[test] + fn test_parse_stability_beta_no_number() { + // "1.0.0-beta" with no number + let v = Version::parse("1.0.0-beta").unwrap(); + assert_eq!(v.pre_release, Some("beta".to_string())); + } + + #[test] + fn test_parse_dev_release_branch() { + // "dev-release-1.0" is a dev branch named "release-1.0" + let v = Version::parse("dev-release-1.0").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("release-1.0".to_string())); + assert_eq!(v.pre_release, Some("dev".to_string())); + } + + #[test] + fn test_parse_dev_master_uppercase() { + // "DEV-master" — case-insensitive dev- prefix + let v = Version::parse("DEV-master").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("master".to_string())); + } + + #[test] + fn test_parse_x_dev_two_segment() { + // "2.x-dev" → major=2, minor=0, patch=9999999, build=9999999 + let v = Version::parse("2.x-dev").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.major, 2); + assert_eq!(v.minor, 0); + assert_eq!(v.patch, 9999999); + assert_eq!(v.build, 9999999); + } + + #[test] + fn test_parse_numeric_dev_suffix() { + // "2.1-dev" — ends with -dev, treated as *-dev suffix branch + let v = Version::parse("2.1-dev").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.major, 2); + assert_eq!(v.minor, 1); + } + + #[test] + fn test_parse_stability_flag_dev() { + // "1.0.0@dev" → strip @dev suffix, parse 1.0.0 as stable + let v = Version::parse("1.0.0@dev").unwrap(); + assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); + assert!(!v.is_dev_branch); + // After stripping @dev, no pre-release suffix remains + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_stability_flag_alpha() { + let v = Version::parse("1.0.0@alpha").unwrap(); + assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_stability_flag_beta() { + let v = Version::parse("1.0.0@beta").unwrap(); + assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_stability_flag_rc() { + let v = Version::parse("1.0.0@rc").unwrap(); + assert_eq!((v.major, v.minor, v.patch), (1, 0, 0)); + assert_eq!(v.pre_release, None); + } + + #[test] + fn test_parse_inline_alias_left_side() { + // "dev-main as 1.0.x-dev" → left side is "dev-main" + let v = Version::parse("dev-main as 1.0.x-dev").unwrap(); + assert!(v.is_dev_branch); + assert_eq!(v.dev_branch_name, Some("main".to_string())); + } + + #[test] + fn test_parse_error_empty_string() { + let result = Version::parse(""); + assert!(result.is_err(), "Expected error for empty string"); + } + + #[test] + fn test_parse_error_not_a_version() { + // Strings with no numeric start should fail + let result = Version::parse("not-a-version"); + assert!( + result.is_err(), + "Expected error for 'not-a-version', got: {:?}", + result + ); + } + + #[test] + fn test_parse_error_only_dots() { + let result = Version::parse("...."); + assert!(result.is_err(), "Expected error for '....'"); + } + + #[test] + fn test_parse_error_non_numeric_segment() { + // "1.abc.3" — minor segment is non-numeric; parse degrades minor to 0 + // The implementation uses `and_then(|p| p.parse().ok()).unwrap_or(0)`, + // so non-numeric segments silently become 0. This is intentional behavior. + let v = Version::parse("1.abc.3").unwrap(); + assert_eq!(v.major, 1); + // minor "abc" fails to parse as u64, so falls back to 0 + assert_eq!(v.minor, 0); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 2. VERSION ORDERING + // ══════════════════════════════════════════════════════════════════════════ + + #[test] + fn test_ordering_equal_versions() { + let a = Version::parse("1.2.3").unwrap(); + let b = Version::parse("1.2.3").unwrap(); + assert_eq!(a.cmp(&b), std::cmp::Ordering::Equal); + } + + #[test] + fn test_ordering_patch_difference() { + let a = Version::parse("1.2.4").unwrap(); + let b = Version::parse("1.2.3").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_build_segment_difference() { + let a = Version::parse("1.2.3.2").unwrap(); + let b = Version::parse("1.2.3.1").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_dev_branch_lt_dev_prerelease() { + // "1.0.0-dev" ends with "-dev", so the parser treats it as a *-dev suffix branch + // (is_dev_branch=true, dev_branch_name=None, major=1, minor=0, patch=9999999). + // "dev-master" is also is_dev_branch=true with dev_branch_name=Some("master"). + // When both are dev branches, they compare by dev_branch_name: + // Some("master") vs None → Some > None, so dev-master > 1.0.0-dev (x-dev form). + let dev_branch = Version::parse("dev-master").unwrap(); + let dev_prerelease = Version::parse("1.0.0-dev").unwrap(); + // Both are dev branches; "master" branch name > None → dev-master is Greater + assert!(dev_branch > dev_prerelease); + } + + #[test] + fn test_ordering_dev_prerelease_lt_alpha() { + let dev = Version::parse("1.0.0-dev").unwrap(); + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + assert!(dev < alpha); + } + + #[test] + fn test_ordering_alpha_lt_beta() { + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + let beta = Version::parse("1.0.0-beta1").unwrap(); + assert!(alpha < beta); + } + + #[test] + fn test_ordering_beta_lt_rc() { + let beta = Version::parse("1.0.0-beta1").unwrap(); + let rc = Version::parse("1.0.0-RC1").unwrap(); + assert!(beta < rc); + } + + #[test] + fn test_ordering_rc_lt_stable() { + let rc = Version::parse("1.0.0-RC1").unwrap(); + let stable = Version::parse("1.0.0").unwrap(); + assert!(rc < stable); + } + + #[test] + fn test_ordering_stable_lt_patch() { + // The Ord impl: (None, Some(_)) => Greater — stable (pre_release=None) beats any + // pre_release including "patch1". Even though stability_rank("patch")=5 which is + // higher than stable's implicit 0, that path is only reached when both sides are + // Some(_). Since stable has pre_release=None, stable > patch version. + let stable = Version::parse("1.0.0").unwrap(); + let patch = Version::parse("1.0.0-patch1").unwrap(); + assert!(stable > patch); + } + + #[test] + fn test_ordering_rc3_gt_rc2() { + let rc3 = Version::parse("1.0.0-RC3").unwrap(); + let rc2 = Version::parse("1.0.0-RC2").unwrap(); + assert!(rc3 > rc2); + } + + #[test] + fn test_ordering_alpha5_gt_alpha3() { + let a5 = Version::parse("1.0.0-alpha5").unwrap(); + let a3 = Version::parse("1.0.0-alpha3").unwrap(); + assert!(a5 > a3); + } + + #[test] + fn test_ordering_dev_branches_alphabetical() { + // Between two dev branches, compare branch names alphabetically + let dev_foo = Version::parse("dev-foo").unwrap(); + let dev_bar = Version::parse("dev-bar").unwrap(); + // "bar" < "foo" alphabetically + assert!(dev_foo > dev_bar); + } + + #[test] + fn test_ordering_zero_versions() { + let a = Version::parse("0.0.2").unwrap(); + let b = Version::parse("0.0.1").unwrap(); + assert!(a > b); + } + + #[test] + fn test_ordering_four_vs_three_segment_equal() { + // 1.2.3.0 and 1.2.3 should be equal (build defaults to 0) + let a = Version::parse("1.2.3.0").unwrap(); + let b = Version::parse("1.2.3").unwrap(); + assert_eq!(a, b); + } + + #[test] + fn test_ordering_comprehensive_chain() { + // Note: "1.0.0-dev" is parsed as a *-dev suffix branch (is_dev_branch=true, + // dev_branch_name=None) due to the "-dev" suffix rule in Version::parse. + // "dev-foo" is also a dev branch (is_dev_branch=true, dev_branch_name=Some("foo")). + // Comparing two dev branches uses dev_branch_name: None < Some("foo"), so + // the *-dev form (None) < "dev-foo" (Some("foo")). + // For "1.0.0-alpha1", "1.0.0-beta1", "1.0.0-RC1", "1.0.0": normal numeric ordering. + let dev_x_dev = Version::parse("1.0.0-dev").unwrap(); // *-dev branch, name=None + let dev_branch = Version::parse("dev-foo").unwrap(); // named branch, name=Some("foo") + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + let beta = Version::parse("1.0.0-beta1").unwrap(); + let rc = Version::parse("1.0.0-RC1").unwrap(); + let stable = Version::parse("1.0.0").unwrap(); + + // Both dev branches; dev_branch_name None < Some("foo") + assert!(dev_x_dev < dev_branch); + // dev_branch (is_dev_branch=true) < alpha (is_dev_branch=false) + assert!(dev_branch < alpha); + assert!(alpha < beta); + assert!(beta < rc); + assert!(rc < stable); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 3. CONSTRAINT PARSING EDGE CASES + // ══════════════════════════════════════════════════════════════════════════ + + // ── Caret ── + + #[test] + fn test_caret_zero_zero_three() { + // ^0.0.3 → >=0.0.3 <0.0.4 + assert!(satisfies("^0.0.3", "0.0.3")); + assert!(!satisfies("^0.0.3", "0.0.4")); + assert!(!satisfies("^0.0.3", "0.0.2")); + } + + #[test] + fn test_caret_zero_zero_zero() { + // ^0.0.0 → first non-zero is none, upper = 0.0.1 + assert!(satisfies("^0.0.0", "0.0.0")); + assert!(!satisfies("^0.0.0", "0.0.1")); + } + + #[test] + fn test_caret_single_major() { + // ^1 → >=1.0.0 <2.0.0 + assert!(satisfies("^1", "1.0.0")); + assert!(satisfies("^1", "1.99.99")); + assert!(!satisfies("^1", "2.0.0")); + assert!(!satisfies("^1", "0.9.9")); + } + + #[test] + fn test_caret_four_segments() { + // ^1.2.3.4 → >=1.2.3.4 <2.0.0.0 + assert!(satisfies("^1.2.3.4", "1.2.3.4")); + assert!(satisfies("^1.2.3.4", "1.9.0.0")); + assert!(!satisfies("^1.2.3.4", "2.0.0.0")); + assert!(!satisfies("^1.2.3.4", "1.2.3.3")); + } + + #[test] + fn test_caret_lower_boundary() { + // ^1.2.3 lower boundary: 1.2.3 matches but 1.2.2 does not + assert!(satisfies("^1.2.3", "1.2.3")); + assert!(!satisfies("^1.2.3", "1.2.2")); + } + + #[test] + fn test_caret_upper_boundary() { + // ^1.2.3 upper boundary: 1.9.9 matches, 2.0.0 does not + assert!(satisfies("^1.2.3", "1.9.9")); + assert!(!satisfies("^1.2.3", "2.0.0")); + } + + // ── Tilde ── + + #[test] + fn test_tilde_single_major() { + // ~1 → >=1.0.0 <2.0.0 + assert!(satisfies("~1", "1.0.0")); + assert!(satisfies("~1", "1.99.0")); + assert!(!satisfies("~1", "2.0.0")); + assert!(!satisfies("~1", "0.9.9")); + } + + #[test] + fn test_tilde_four_segments() { + // ~1.2.3.4 → >=1.2.3.4 <1.3.0.0 + assert!(satisfies("~1.2.3.4", "1.2.3.4")); + assert!(satisfies("~1.2.9.0", "1.2.9.0")); + assert!(!satisfies("~1.2.3.4", "1.3.0.0")); + assert!(!satisfies("~1.2.3.4", "1.2.3.3")); + } + + #[test] + fn test_tilde_lower_boundary() { + // ~1.2.3: 1.2.3 matches, 1.2.2 does not + assert!(satisfies("~1.2.3", "1.2.3")); + assert!(!satisfies("~1.2.3", "1.2.2")); + } + + #[test] + fn test_tilde_upper_boundary() { + // ~1.2.3: 1.2.9 matches, 1.3.0 does not + assert!(satisfies("~1.2.3", "1.2.9")); + assert!(!satisfies("~1.2.3", "1.3.0")); + } + + // ── Wildcard ── + + #[test] + fn test_wildcard_major_only() { + // 1.* → >=1.0.0 <2.0.0 + assert!(satisfies("1.*", "1.0.0")); + assert!(satisfies("1.*", "1.99.0")); + assert!(!satisfies("1.*", "2.0.0")); + assert!(!satisfies("1.*", "0.9.9")); + } + + #[test] + fn test_wildcard_double_star() { + // 1.*.* is treated like 1.* + assert!(satisfies("1.*.*", "1.5.0")); + assert!(!satisfies("1.*.*", "2.0.0")); + } + + #[test] + fn test_wildcard_three_segment() { + // 1.2.3.* — the implementation strips trailing .*; base is "1.2.3" + // parse_wildcard strips .* and splits on '.'; parts.len()=3 → minor constraint + assert!(satisfies("1.2.3.*", "1.2.3")); + assert!(satisfies("1.2.3.*", "1.2.9")); + assert!(!satisfies("1.2.3.*", "1.3.0")); + } + + #[test] + fn test_wildcard_zero_major() { + // 0.* → >=0.0.0 <1.0.0 + assert!(satisfies("0.*", "0.0.0")); + assert!(satisfies("0.*", "0.99.0")); + assert!(!satisfies("0.*", "1.0.0")); + } + + #[test] + fn test_wildcard_v_prefix() { + // v1.* — the wildcard parser strips the trailing .*; base becomes "v1" + // parse_wildcard's base.split('.') on "v1" → single part "v1" + // v1 fails to parse as u64, falls back to 0 — so this is like 0.* + // Mark as ignore since the behavior diverges from the expected semantic + #[allow(unused)] + let _ = VersionConstraint::parse("v1.*"); // just verify it doesn't panic + } + + // ── Hyphen ranges ── + + #[test] + fn test_hyphen_range_partial_from() { + // "1.0 - 2.0": 1.0 is a partial "from", lower = >=1.0.0 + assert!(satisfies("1.0 - 2.0", "1.0.0")); + assert!(satisfies("1.0 - 2.0", "1.5.0")); + } + + #[test] + fn test_hyphen_range_partial_to() { + // "1.0 - 2.0": upper = <=2.0.0 (inclusive) + assert!(satisfies("1.0 - 2.0", "2.0.0")); + assert!(!satisfies("1.0 - 2.0", "2.0.1")); + } + + #[test] + fn test_hyphen_range_same_version() { + // "1.0.0 - 1.0.0" → >=1.0.0 <=1.0.0, matches only 1.0.0 + assert!(satisfies("1.0.0 - 1.0.0", "1.0.0")); + assert!(!satisfies("1.0.0 - 1.0.0", "1.0.1")); + assert!(!satisfies("1.0.0 - 1.0.0", "0.9.9")); + } + + #[test] + fn test_hyphen_range_with_prerelease() { + // "1.0.0-alpha1 - 1.0.0-RC1" + assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-alpha1")); + assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-beta1")); + assert!(satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0-RC1")); + assert!(!satisfies("1.0.0-alpha1 - 1.0.0-RC1", "1.0.0")); + } + + // ── Comparison operators ── + + #[test] + fn test_gt_boundary() { + assert!(!satisfies(">1.0.0", "1.0.0")); + assert!(satisfies(">1.0.0", "1.0.1")); + } + + #[test] + fn test_lt_boundary() { + assert!(!satisfies("<1.0.0", "1.0.0")); + assert!(satisfies("<1.0.0", "0.9.9")); + } + + #[test] + fn test_lte_boundary() { + assert!(satisfies("<=1.0.0", "1.0.0")); + assert!(!satisfies("<=1.0.0", "1.0.1")); + } + + #[test] + fn test_exact_equals_sign() { + // "=1.2.3" is exact match + assert!(satisfies("=1.2.3", "1.2.3")); + assert!(!satisfies("=1.2.3", "1.2.4")); + } + + #[test] + #[ignore = "== (double equals) is not supported: the '=' handler passes '=1.2.3' to \ + Version::parse_for_constraint which fails to parse '=1' as a major number"] + fn test_double_equals_sign() { + // "==1.2.3" — the '=' branch strips one '=', leaving "=1.2.3", which is then + // passed to Version::parse_for_constraint. That function tries to parse "=1" as + // a major version number and fails. Double-equals is not a supported syntax. + assert!(satisfies("==1.2.3", "1.2.3")); + assert!(!satisfies("==1.2.3", "1.2.4")); + } + + #[test] + fn test_not_equal_boundary() { + assert!(!satisfies("!=1.5.0", "1.5.0")); + assert!(satisfies("!=1.5.0", "1.4.9")); + assert!(satisfies("!=1.5.0", "1.5.1")); + } + + #[test] + fn test_gte_with_spaces() { + // Spaces after operator should be handled + assert!(satisfies(">=1.0.0", "1.0.0")); + } + + // ── AND constraints ── + + #[test] + fn test_and_comma_separated() { + // Comma-separated constraints act as AND + assert!(satisfies(">=1.0,<2.0", "1.5.0")); + assert!(!satisfies(">=1.0,<2.0", "2.0.0")); + assert!(!satisfies(">=1.0,<2.0", "0.9.0")); + } + + #[test] + fn test_and_three_way() { + assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.3.0")); + assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "1.5.0")); + assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "2.0.0")); + } + + #[test] + fn test_and_impossible_range() { + // >=2.0 <1.0 — impossible range, nothing should match + assert!(!satisfies(">=2.0 <1.0", "1.5.0")); + assert!(!satisfies(">=2.0 <1.0", "2.0.0")); + assert!(!satisfies(">=2.0 <1.0", "0.5.0")); + } + + #[test] + fn test_and_tight_range() { + // >=1.2.3 <=1.2.3 — only exactly 1.2.3 + assert!(satisfies(">=1.2.3 <=1.2.3", "1.2.3")); + assert!(!satisfies(">=1.2.3 <=1.2.3", "1.2.4")); + assert!(!satisfies(">=1.2.3 <=1.2.3", "1.2.2")); + } + + // ── OR constraints ── + + #[test] + fn test_or_double_pipe() { + assert!(satisfies("^1.0 || ^2.0", "1.5.0")); + assert!(satisfies("^1.0 || ^2.0", "2.3.0")); + assert!(!satisfies("^1.0 || ^2.0", "3.0.0")); + } + + #[test] + fn test_or_three_branches() { + assert!(satisfies("^1.0 || ^2.0 || ^3.0", "1.0.0")); + assert!(satisfies("^1.0 || ^2.0 || ^3.0", "2.5.0")); + assert!(satisfies("^1.0 || ^2.0 || ^3.0", "3.9.9")); + assert!(!satisfies("^1.0 || ^2.0 || ^3.0", "4.0.0")); + } + + #[test] + fn test_or_with_wildcard() { + assert!(satisfies("1.* || 3.*", "1.5.0")); + assert!(satisfies("1.* || 3.*", "3.0.0")); + assert!(!satisfies("1.* || 3.*", "2.0.0")); + } + + #[test] + fn test_or_overlapping_ranges() { + // Overlapping ranges are fine — union semantics + assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "1.5.0")); + assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "2.5.0")); + assert!(satisfies(">=1.0 <3.0 || >=2.0 <4.0", "3.5.0")); + assert!(!satisfies(">=1.0 <3.0 || >=2.0 <4.0", "0.9.0")); + assert!(!satisfies(">=1.0 <3.0 || >=2.0 <4.0", "4.0.0")); + } + + #[test] + fn test_or_exact_versions() { + assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "1.0.0")); + assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "2.0.0")); + assert!(satisfies("1.0.0 || 2.0.0 || 3.0.0", "3.0.0")); + assert!(!satisfies("1.0.0 || 2.0.0 || 3.0.0", "1.0.1")); + } + + // ── Complex combined ── + + #[test] + fn test_combined_and_within_or() { + // ">=1.0 <2.0 || >=3.0 <4.0" + assert!(satisfies(">=1.0 <2.0 || >=3.0 <4.0", "1.5.0")); + assert!(satisfies(">=1.0 <2.0 || >=3.0 <4.0", "3.5.0")); + assert!(!satisfies(">=1.0 <2.0 || >=3.0 <4.0", "2.5.0")); + assert!(!satisfies(">=1.0 <2.0 || >=3.0 <4.0", "4.0.0")); + } + + #[test] + fn test_combined_real_world_laravel_pattern() { + // "^8.0||^9.0||^10.0||^11.0" — real Laravel constraint + assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "8.5.0")); + assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "9.0.0")); + assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "10.48.22")); + assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "11.0.1")); + assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "7.9.9")); + assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "12.0.0")); + } + + #[test] + fn test_single_pipe_or() { + // Single pipe `|` is the standard Composer OR separator + assert!(satisfies("^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "6.0.0")); + assert!(satisfies("^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "9.0.0")); + assert!(satisfies("^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "11.5.0")); + assert!(!satisfies("^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "5.9.9")); + assert!(!satisfies("^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "12.0.0")); + } + + #[test] + fn test_combined_real_world_symfony_pattern() { + // ">=5.4 <7.0" — typical Symfony range + assert!(satisfies(">=5.4 <7.0", "5.4.0")); + assert!(satisfies(">=5.4 <7.0", "6.4.5")); + assert!(!satisfies(">=5.4 <7.0", "5.3.9")); + assert!(!satisfies(">=5.4 <7.0", "7.0.0")); + } + + // ── Edge cases ── + + #[test] + fn test_constraint_empty_string_is_any() { + // Empty string → Any constraint + let c = VersionConstraint::parse("*").unwrap(); + let v = Version::parse("9.9.9").unwrap(); + assert!(c.matches(&v)); + } + + #[test] + fn test_constraint_v_prefix_in_exact() { + // "v1.2.3" exact constraint — strip v prefix + assert!(satisfies("v1.2.3", "1.2.3")); + assert!(!satisfies("v1.2.3", "1.2.4")); + } + + #[test] + fn test_constraint_extra_whitespace_and() { + // Extra spaces around operators in AND groups + assert!(satisfies(">=1.0.0 <2.0.0", "1.5.0")); + assert!(!satisfies(">=1.0.0 <2.0.0", "2.0.0")); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 4. CONSTRAINT MATCHING + // ══════════════════════════════════════════════════════════════════════════ + + #[test] + fn test_dev_branch_exact_match() { + // dev-master matches dev-master constraint exactly + let c = VersionConstraint::parse("dev-master").unwrap(); + let v = Version::parse("dev-master").unwrap(); + assert!(c.matches(&v)); + } + + #[test] + fn test_dev_branch_different_branch_no_match() { + let c = VersionConstraint::parse("dev-master").unwrap(); + let v = Version::parse("dev-develop").unwrap(); + assert!(!c.matches(&v)); + } + + #[test] + fn test_dev_branch_against_caret_no_match() { + // dev-master does not satisfy ^1.0 (it is a dev branch, always lowest) + let c = VersionConstraint::parse("^1.0").unwrap(); + let v = Version::parse("dev-master").unwrap(); + assert!(!c.matches(&v)); + } + + #[test] + fn test_any_constraint_matches_dev_branch() { + // "*" matches any version including dev branches + let c = VersionConstraint::parse("*").unwrap(); + let v = Version::parse("dev-master").unwrap(); + assert!(c.matches(&v)); + } + + #[test] + fn test_prerelease_within_caret_range() { + // Pre-release of a version within ^1.0 should match + // e.g. 1.5.0-beta1 — it is >= dev boundary 1.0.0 and < dev boundary 2.0.0 + assert!(satisfies("^1.0", "1.5.0-beta1")); + } + + #[test] + fn test_caret_lower_minus_one_no_match() { + // ^1.2.3 lower-1 = 1.2.2 → should NOT match + assert!(!satisfies("^1.2.3", "1.2.2")); + } + + #[test] + fn test_caret_upper_minus_one_matches() { + // ^1.2.3 upper-1 patch: 1.9.9 should still match (below 2.0.0) + assert!(satisfies("^1.2.3", "1.9.9")); + } + + #[test] + fn test_tilde_lower_minus_one_no_match() { + assert!(!satisfies("~1.2.3", "1.2.2")); + } + + #[test] + fn test_tilde_upper_minus_one_matches() { + assert!(satisfies("~1.2.3", "1.2.9")); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 5. INTERNAL FUNCTION TESTS (via public API) + // ══════════════════════════════════════════════════════════════════════════ + + // stability_rank() — tested via ordering since the function is private + + #[test] + fn test_stability_rank_dev_via_ordering() { + // dev rank=50 (highest number = least stable), alpha rank=40 + // So dev < alpha in version ordering terms + let dev = Version::parse("1.0.0-dev").unwrap(); + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + assert!(dev < alpha, "dev should be less stable than alpha1"); + } + + #[test] + fn test_stability_rank_alpha_via_ordering() { + // alpha rank=40, beta rank=30 + let alpha = Version::parse("1.0.0-alpha1").unwrap(); + let beta = Version::parse("1.0.0-beta1").unwrap(); + assert!(alpha < beta, "alpha should be less stable than beta"); + } + + #[test] + fn test_stability_rank_beta_via_ordering() { + // beta rank=30, RC rank=20 + let beta = Version::parse("1.0.0-beta1").unwrap(); + let rc = Version::parse("1.0.0-RC1").unwrap(); + assert!(beta < rc, "beta should be less stable than RC"); + } + + #[test] + fn test_stability_rank_rc_via_ordering() { + // RC rank=20, stable rank=0 + let rc = Version::parse("1.0.0-RC1").unwrap(); + let stable = Version::parse("1.0.0").unwrap(); + assert!(rc < stable, "RC should be less stable than stable"); + } + + #[test] + fn test_stability_rank_patch_via_ordering() { + // The Ord impl: (None, Some(_)) => Greater. + // stable has pre_release=None; patch version has pre_release=Some("patch1"). + // The None arm wins unconditionally: stable is always Greater than any pre_release. + // This means "patch" releases (post-release fixes) sort BELOW stable in this impl. + let patch_ver = Version::parse("1.0.0-patch1").unwrap(); + let stable = Version::parse("1.0.0").unwrap(); + assert!( + stable > patch_ver, + "stable (None pre_release) beats patch pre-release" + ); + } + + // normalize_pre_release() — tested via Version::parse pre_release field + + #[test] + fn test_normalize_pre_release_b_to_beta() { + let v = Version::parse("1.0.0-b3").unwrap(); + assert_eq!(v.pre_release, Some("beta3".to_string())); + } + + #[test] + fn test_normalize_pre_release_a_to_alpha() { + let v = Version::parse("1.0.0-a1").unwrap(); + assert_eq!(v.pre_release, Some("alpha1".to_string())); + } + + #[test] + fn test_normalize_pre_release_rc_to_rc_uppercase() { + let v = Version::parse("1.0.0-rc").unwrap(); + assert_eq!(v.pre_release, Some("RC".to_string())); + } + + #[test] + fn test_normalize_pre_release_pl_to_patch() { + let v = Version::parse("1.0.0-pl2").unwrap(); + assert_eq!(v.pre_release, Some("patch2".to_string())); + } + + #[test] + fn test_normalize_pre_release_patch_explicit() { + let v = Version::parse("1.0.0-patch3").unwrap(); + assert_eq!(v.pre_release, Some("patch3".to_string())); + } + + // pre_release_number() — tested via ordering of numbered pre-releases + + #[test] + fn test_pre_release_number_ordering_beta() { + // beta10 > beta2 if pre_release_number extracts correctly + let b10 = Version::parse("1.0.0-beta10").unwrap(); + let b2 = Version::parse("1.0.0-beta2").unwrap(); + assert!(b10 > b2); + } + + #[test] + fn test_pre_release_number_ordering_rc() { + let rc5 = Version::parse("1.0.0-RC5").unwrap(); + let rc1 = Version::parse("1.0.0-RC1").unwrap(); + assert!(rc5 > rc1); + } + + #[test] + fn test_pre_release_number_zero_when_missing() { + // "alpha" with no number → 0; "alpha1" → 1; alpha1 > alpha + let alpha1 = Version::parse("1.0.0-alpha1").unwrap(); + let alpha = Version::parse("1.0.0-alpha").unwrap(); + assert!(alpha1 > alpha); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 6. COMPOSER BEHAVIORAL COMPATIBILITY + // ══════════════════════════════════════════════════════════════════════════ + + #[test] + fn test_composer_caret_four_matches_minor_bump() { + // ^4.0 matches 4.5.3 + assert!(satisfies("^4.0", "4.5.3")); + } + + #[test] + fn test_composer_caret_four_does_not_match_next_major() { + assert!(!satisfies("^4.0", "5.0.0")); + } + + #[test] + fn test_composer_caret_zero_three_matches_patch() { + // ^0.3 matches 0.3.5 (same minor family) + assert!(satisfies("^0.3", "0.3.5")); + } + + #[test] + fn test_composer_caret_zero_three_does_not_match_next_minor() { + // ^0.3 does NOT match 0.4.0 + assert!(!satisfies("^0.3", "0.4.0")); + } + + #[test] + fn test_composer_tilde_four_one_matches_within_major() { + // ~4.1 → >=4.1.0 <5.0.0 — matches 4.9.0 + assert!(satisfies("~4.1", "4.9.0")); + } + + #[test] + fn test_composer_tilde_four_one_does_not_match_next_major() { + // ~4.1 does NOT match 5.0.0 + assert!(!satisfies("~4.1", "5.0.0")); + } + + #[test] + fn test_composer_range_gap_matches_second_range() { + // ">=1.0 <1.1 || >=1.2" — gap at 1.1.x; 1.2.0 matches + assert!(satisfies(">=1.0 <1.1 || >=1.2", "1.2.0")); + } + + #[test] + fn test_composer_range_gap_does_not_match_in_gap() { + // 1.1.5 is in the gap — should NOT match + assert!(!satisfies(">=1.0 <1.1 || >=1.2", "1.1.5")); + } + + #[test] + fn test_composer_laravel_constraint_matches_v10() { + // "^8.0||^9.0||^10.0||^11.0" — Laravel-style; 10.48.22 matches + assert!(satisfies("^8.0||^9.0||^10.0||^11.0", "10.48.22")); + } + + #[test] + fn test_composer_laravel_constraint_does_not_match_v7() { + assert!(!satisfies("^8.0||^9.0||^10.0||^11.0", "7.9.9")); + } + + #[test] + fn test_composer_symfony_range_matches_6_4() { + // ">=5.4 <7.0" — Symfony; 6.4.5 matches + assert!(satisfies(">=5.4 <7.0", "6.4.5")); + } + + #[test] + fn test_composer_symfony_range_does_not_match_7_0() { + assert!(!satisfies(">=5.4 <7.0", "7.0.0")); + } + + #[test] + fn test_composer_not_equal_in_range() { + // ">=1.0 !=1.5.0 <2.0" — typical blacklist constraint + assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.4.9")); + assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "1.5.0")); + assert!(satisfies(">=1.0 !=1.5.0 <2.0", "1.5.1")); + assert!(!satisfies(">=1.0 !=1.5.0 <2.0", "2.0.0")); + } + + #[test] + fn test_composer_exact_major_minor_match() { + // exact "1.5.0" only matches 1.5.0 + assert!(satisfies("1.5.0", "1.5.0")); + assert!(!satisfies("1.5.0", "1.5.1")); + } + + // ══════════════════════════════════════════════════════════════════════════ + // 7. DIVERGENCE INVESTIGATION + // ══════════════════════════════════════════════════════════════════════════ + + #[test] + fn test_hyphen_range_partial_upper_two_segment() { + // "1.0 - 2": upper is <=2.0.0 (parse "2" → 2.0.0.0, inclusive) + assert!(satisfies("1.0 - 2", "2.0.0")); + assert!(!satisfies("1.0 - 2", "2.0.1")); + assert!(!satisfies("1.0 - 2", "2.1.0")); + } + + #[test] + fn test_caret_with_prerelease_suffix() { + // ^1.2.3-beta1 — the caret parser ignores pre-release in its bounds calculation + // because parse_caret works on the numeric parts only. + // Lower: dev_boundary(1,2,3,0). Upper: dev_boundary(2,0,0,0). + // 1.2.3-beta1 (pre_release=Some("beta1")) is >= lower boundary? + // dev_boundary uses pre_release=Some("dev"), so lower is (1,2,3,0,dev) + // Version 1.2.3-beta1 has same numeric, but beta > dev in stability terms + // so 1.2.3-beta1 >= lower (1.2.3-dev) is true. + assert!(satisfies("^1.2.3-beta1", "1.2.3-beta1")); + assert!(satisfies("^1.2.3-beta1", "1.5.0")); + assert!(!satisfies("^1.2.3-beta1", "2.0.0")); + } + + #[test] + fn test_tilde_with_prerelease_suffix() { + // ~1.2.3-alpha1: lower = dev_boundary(1,2,3,0), upper = dev_boundary(1,3,0,0) + // 1.2.3-alpha1 has numeric (1,2,3,0); pre_release "alpha1" > "dev" + assert!(satisfies("~1.2.3-alpha1", "1.2.3-alpha1")); + assert!(satisfies("~1.2.3-alpha1", "1.2.9")); + assert!(!satisfies("~1.2.3-alpha1", "1.3.0")); + } + + #[test] + fn test_dev_boundary_comparison() { + // Version::dev_boundary creates a version with pre_release=Some("dev") and + // is_dev_branch=false. These should sort correctly against real versions. + let lower = Version::dev_boundary(1, 0, 0, 0); + let v = Version::parse("1.0.0").unwrap(); + // 1.0.0 (stable) > 1.0.0-dev (lower boundary) + assert!(v > lower); + } + + #[test] + fn test_x_dev_ordering_within_range() { + // "2.x-dev" version has patch=9999999, build=9999999 and is a dev branch. + // Dev branches are always lowest. So "2.x-dev" < "2.0.0" < "3.0.0". + let x_dev = Version::parse("2.x-dev").unwrap(); + let stable = Version::parse("2.0.0").unwrap(); + assert!(x_dev < stable); + } + + #[test] + fn test_four_segment_vs_three_segment_constraint() { + // "1.2.3.4" exact constraint — matches only 1.2.3.4, not 1.2.3 + assert!(satisfies("1.2.3.4", "1.2.3.4")); + assert!(!satisfies("1.2.3.4", "1.2.3")); + assert!(!satisfies("1.2.3.4", "1.2.3.5")); + } + + #[test] + fn test_date_style_version_ordering() { + // Date-based versioning: 20230101 > 20220101 + let a = Version::parse("20230101.0.0").unwrap(); + let b = Version::parse("20220101.0.0").unwrap(); + assert!(a > b); + } +} diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml index 18b34bf..443cd9e 100644 --- a/crates/mozart/Cargo.toml +++ b/crates/mozart/Cargo.toml @@ -4,11 +4,11 @@ version.workspace = true edition.workspace = true [dependencies] -mozart-constraint.workspace = true mozart-archiver.workspace = true mozart-autoload.workspace = true mozart-core.workspace = true mozart-registry.workspace = true +mozart-semver.workspace = true anyhow.workspace = true clap.workspace = true clap_complete.workspace = true @@ -21,8 +21,8 @@ serde_json.workspace = true sha1.workspace = true tempfile.workspace = true tokio.workspace = true -tracing.workspace = true tracing-subscriber.workspace = true +tracing.workspace = true [dev-dependencies] assert_cmd.workspace = true diff --git a/crates/mozart/src/commands/audit.rs b/crates/mozart/src/commands/audit.rs index 8f15ef0..2faec19 100644 --- a/crates/mozart/src/commands/audit.rs +++ b/crates/mozart/src/commands/audit.rs @@ -273,7 +273,7 @@ fn filter_advisories( .as_deref() .unwrap_or(pkg.version.as_str()); - let installed_ver = match mozart_constraint::Version::parse(version_str) { + let installed_ver = match mozart_semver::Version::parse(version_str) { Ok(v) => v, Err(_) => { eprintln!( @@ -298,9 +298,7 @@ fn filter_advisories( // Normalize single-pipe OR separators (`|`) to double-pipe (`||`) // since the Packagist API may use either form. let normalized_constraint = normalize_or_separator(&advisory.affected_versions); - let constraint = match mozart_constraint::VersionConstraint::parse( - &normalized_constraint, - ) { + let constraint = match mozart_semver::VersionConstraint::parse(&normalized_constraint) { Ok(c) => c, Err(_) => { eprintln!( diff --git a/crates/mozart/src/commands/check_platform_reqs.rs b/crates/mozart/src/commands/check_platform_reqs.rs index 295f9b7..79a1da7 100644 --- a/crates/mozart/src/commands/check_platform_reqs.rs +++ b/crates/mozart/src/commands/check_platform_reqs.rs @@ -279,21 +279,21 @@ fn check_requirements( } Some(detected) => { // Check all constraints - let detected_version = match mozart_constraint::Version::parse(&detected.version) { + let detected_version = match mozart_semver::Version::parse(&detected.version) { Ok(v) => v, Err(_) => { // Unparseable version → treat as 0.0.0 - mozart_constraint::Version::parse("0.0.0").unwrap() + mozart_semver::Version::parse("0.0.0").unwrap() } }; let mut failed_req: Option<(String, String)> = None; for req in reqs { - let constraint = - match mozart_constraint::VersionConstraint::parse(&req.constraint) { - Ok(c) => c, - Err(_) => continue, // skip unparseable constraints - }; + let constraint = match mozart_semver::VersionConstraint::parse(&req.constraint) + { + Ok(c) => c, + Err(_) => continue, // skip unparseable constraints + }; if !constraint.matches(&detected_version) { failed_req = Some((req.constraint.clone(), req.provider.clone())); break; diff --git a/crates/mozart/src/commands/dependency.rs b/crates/mozart/src/commands/dependency.rs index 4714de2..5073be0 100644 --- a/crates/mozart/src/commands/dependency.rs +++ b/crates/mozart/src/commands/dependency.rs @@ -192,7 +192,7 @@ fn load_from_installed(working_dir: &Path) -> Result> { pub fn get_dependents( packages: &[PackageInfo], needles: &[String], - constraint: Option<&mozart_constraint::VersionConstraint>, + constraint: Option<&mozart_semver::VersionConstraint>, inverted: bool, recursive: bool, ) -> Result> { @@ -317,7 +317,7 @@ fn recurse_dependents( fn get_prohibitors( packages: &[PackageInfo], needles: &[String], - constraint: Option<&mozart_constraint::VersionConstraint>, + constraint: Option<&mozart_semver::VersionConstraint>, _recursive: bool, ) -> Result> { let mut results: Vec = Vec::new(); @@ -333,7 +333,7 @@ fn get_prohibitors( .find(|(k, _)| k.to_lowercase() == needle_lower) && let Some(requested_version) = constraint && let Ok(pkg_constraint) = - mozart_constraint::VersionConstraint::parse(req_constraint_str) + mozart_semver::VersionConstraint::parse(req_constraint_str) { // The package requires `needle` but with a different // (incompatible) constraint — it blocks the requested version. @@ -359,7 +359,7 @@ fn get_prohibitors( .find(|(k, _)| k.to_lowercase() == needle_lower) && let Some(requested_version) = constraint && let Ok(pkg_constraint) = - mozart_constraint::VersionConstraint::parse(req_constraint_str) + mozart_semver::VersionConstraint::parse(req_constraint_str) && constraint_prohibits(requested_version, &pkg_constraint) { results.push(DependencyResult { @@ -380,7 +380,7 @@ fn get_prohibitors( .find(|(k, _)| k.to_lowercase() == needle_lower) && let Some(requested_version) = constraint && let Ok(conflict_constraint) = - mozart_constraint::VersionConstraint::parse(conflict_constraint_str) + mozart_semver::VersionConstraint::parse(conflict_constraint_str) { // If the conflict constraint overlaps with (matches) the // requested version range, this package conflicts with it. @@ -408,8 +408,8 @@ fn get_prohibitors( /// We sample a set of "representative versions" from the requested constraint /// and check whether none of them satisfy the package's constraint. fn constraint_prohibits( - requested: &mozart_constraint::VersionConstraint, - pkg_constraint: &mozart_constraint::VersionConstraint, + requested: &mozart_semver::VersionConstraint, + pkg_constraint: &mozart_semver::VersionConstraint, ) -> bool { // We try to determine if there is any version satisfying *requested* that // does NOT satisfy *pkg_constraint*. @@ -430,8 +430,8 @@ fn constraint_prohibits( /// That is, if the conflict constraint matches at least one version that the /// requested constraint also matches. fn constraint_overlaps( - requested: &mozart_constraint::VersionConstraint, - conflict_constraint: &mozart_constraint::VersionConstraint, + requested: &mozart_semver::VersionConstraint, + conflict_constraint: &mozart_semver::VersionConstraint, ) -> bool { let probes = sample_versions_from_constraint(requested); if probes.is_empty() { @@ -446,9 +446,9 @@ fn constraint_overlaps( /// constraint. These are used for the "does this constraint overlap/prohibit /// that constraint?" heuristic. fn sample_versions_from_constraint( - constraint: &mozart_constraint::VersionConstraint, -) -> Vec { - use mozart_constraint::Version; + constraint: &mozart_semver::VersionConstraint, +) -> Vec { + use mozart_semver::Version; // Broad grid of versions to probe let candidates: &[&str] = &[ @@ -685,7 +685,7 @@ mod tests { make_pkg("root/project", "ROOT", &[("vendor/a", "^1.0")], &[], true), make_pkg("vendor/a", "1.0.0", &[], &[], false), ]; - let constraint = mozart_constraint::VersionConstraint::parse("2.0.0").unwrap(); + let constraint = mozart_semver::VersionConstraint::parse("2.0.0").unwrap(); let needles = vec!["vendor/a".to_string()]; let results = get_dependents(&packages, &needles, Some(&constraint), true, false).unwrap(); assert!(!results.is_empty(), "root should prohibit vendor/a 2.0"); @@ -713,7 +713,7 @@ mod tests { false, ), ]; - let constraint = mozart_constraint::VersionConstraint::parse("2.0.0").unwrap(); + let constraint = mozart_semver::VersionConstraint::parse("2.0.0").unwrap(); let needles = vec!["vendor/a".to_string()]; let results = get_dependents(&packages, &needles, Some(&constraint), true, false).unwrap(); // vendor/b conflicts with vendor/a ^2.0 which covers 2.0.0 @@ -733,7 +733,7 @@ mod tests { make_pkg("root/project", "ROOT", &[("vendor/a", "^2.0")], &[], true), make_pkg("vendor/a", "2.0.0", &[], &[], false), ]; - let constraint = mozart_constraint::VersionConstraint::parse("2.5.0").unwrap(); + let constraint = mozart_semver::VersionConstraint::parse("2.5.0").unwrap(); let needles = vec!["vendor/a".to_string()]; let results = get_dependents(&packages, &needles, Some(&constraint), true, false).unwrap(); assert!( diff --git a/crates/mozart/src/commands/outdated.rs b/crates/mozart/src/commands/outdated.rs index eba0d52..1807435 100644 --- a/crates/mozart/src/commands/outdated.rs +++ b/crates/mozart/src/commands/outdated.rs @@ -371,8 +371,8 @@ fn classify_update( // We have an update available — classify it if let Some(constraint_str) = root_constraint - && let Ok(constraint) = mozart_constraint::VersionConstraint::parse(constraint_str) - && let Ok(latest_ver) = mozart_constraint::Version::parse(latest_normalized) + && let Ok(constraint) = mozart_semver::VersionConstraint::parse(constraint_str) + && let Ok(latest_ver) = mozart_semver::Version::parse(latest_normalized) { if constraint.matches(&latest_ver) { return UpdateCategory::SemverCompatible; diff --git a/crates/mozart/src/commands/prohibits.rs b/crates/mozart/src/commands/prohibits.rs index 1f45b27..ca3bc35 100644 --- a/crates/mozart/src/commands/prohibits.rs +++ b/crates/mozart/src/commands/prohibits.rs @@ -43,7 +43,7 @@ pub async fn execute( } // Parse the version constraint the user is asking about - let version_constraint = mozart_constraint::VersionConstraint::parse(&args.version) + let version_constraint = mozart_semver::VersionConstraint::parse(&args.version) .map_err(|e| anyhow::anyhow!("Invalid version constraint '{}': {}", args.version, e))?; let recursive = args.tree || args.recursive; -- cgit v1.3.1