agpm_cli/version/mod.rs
1//! Version constraint parsing, comparison, and resolution for AGPM dependencies.
2//!
3//! This module provides comprehensive version management for AGPM, handling semantic
4//! versioning, Git references, and dependency resolution. It supports multiple version
5//! specification formats and provides sophisticated constraint resolution with conflict
6//! detection and prerelease handling.
7//!
8//! # Module Organization
9//!
10//! - [`constraints`] - Version constraint parsing, sets, and resolution
11//! - [`comparison`] - Version comparison utilities and analysis
12//! - Core types and functions for Git tag resolution
13//!
14//! # Version Specifications
15//!
16//! AGPM supports several version specification formats:
17//!
18//! ## Semantic Versions
19//! - **Exact versions**: `"1.0.0"` - Matches exactly the specified version
20//! - **Caret ranges**: `"^1.0.0"` - Compatible within major version (1.x.x)
21//! - **Tilde ranges**: `"~1.2.0"` - Compatible within minor version (1.2.x)
22//! - **Comparison ranges**: `">=1.0.0"`, `"<2.0.0"`, `">=1.0.0, <2.0.0"`
23//!
24//! ## Special Keywords
25//! - **Wildcard**: `"*"` - Matches any version
26//!
27//! ## Git References
28//! - **Branches**: `"main"`, `"develop"`, `"feature/auth"`
29//! - **Tags**: `"v1.0.0"`, `"release-2023-01"`
30//! - **Commits**: `"abc123..."` (full or abbreviated SHA)
31//!
32//! # Version Resolution Strategy
33//!
34//! The version resolution system follows this process:
35//!
36//! 1. **Tag Discovery**: Fetch all tags from the Git repository
37//! 2. **Semantic Parsing**: Parse tags as semantic versions where possible
38//! 3. **Constraint Matching**: Apply version constraints to find candidates
39//! 4. **Best Selection**: Choose the highest compatible version
40//! 5. **Fallback Handling**: Use branches or commits if no tags match
41//!
42//! # Constraint Resolution Features
43//!
44//! - **Multi-constraint support**: Combine multiple constraints per dependency
45//! - **Conflict detection**: Prevent impossible constraint combinations
46//! - **Prerelease handling**: Sophisticated alpha/beta/RC version management
47//! - **Cross-dependency resolution**: Resolve entire dependency graphs
48//!
49//! # Examples
50//!
51//! ## Basic Git Tag Resolution
52//!
53//! ```rust,no_run
54//! use agpm_cli::version::{VersionResolver, VersionInfo};
55//! use agpm_cli::git::GitRepo;
56//! use std::path::PathBuf;
57//!
58//! # async fn example() -> anyhow::Result<()> {
59//! let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
60//! let resolver = VersionResolver::from_git_tags(&repo).await?;
61//!
62//! // Resolve different constraint types
63//! if let Ok(Some(version)) = resolver.resolve("^1.0.0") {
64//! println!("Resolved caret constraint to: {}", version.tag);
65//! }
66//! # Ok(())
67//! # }
68//! ```
69//!
70//! ## Advanced Constraint Resolution
71//!
72//! ```rust,no_run
73//! use agpm_cli::version::constraints::{ConstraintResolver, VersionConstraint};
74//! use semver::Version;
75//! use std::collections::HashMap;
76//!
77//! # fn example() -> anyhow::Result<()> {
78//! let mut resolver = ConstraintResolver::new();
79//!
80//! // Add constraints for multiple dependencies
81//! resolver.add_constraint("web-framework", "^2.0.0")?;
82//! resolver.add_constraint("database", "~1.5.0")?;
83//! resolver.add_constraint("auth-lib", ">=3.0.0")?;
84//!
85//! // Provide available versions
86//! let mut available = HashMap::new();
87//! available.insert("web-framework".to_string(), vec![Version::parse("2.1.0")?]);
88//! available.insert("database".to_string(), vec![Version::parse("1.5.3")?]);
89//! available.insert("auth-lib".to_string(), vec![Version::parse("3.2.0")?]);
90//!
91//! // Resolve all dependencies simultaneously
92//! let resolved = resolver.resolve(&available)?;
93//! println!("Resolved {} dependencies", resolved.len());
94//! # Ok(())
95//! # }
96//! ```
97//!
98//! ## Version Comparison and Analysis
99//!
100//! ```rust,no_run
101//! use agpm_cli::version::comparison::VersionComparator;
102//!
103//! # fn example() -> anyhow::Result<()> {
104//! let available_versions = vec![
105//! "v1.0.0".to_string(),
106//! "v1.5.0".to_string(),
107//! "v2.0.0".to_string(),
108//! ];
109//!
110//! // Check for newer versions
111//! let has_updates = VersionComparator::has_newer_version("1.2.0", &available_versions)?;
112//! println!("Updates available: {}", has_updates);
113//!
114//! // Get all newer versions sorted by recency
115//! let newer = VersionComparator::get_newer_versions("1.2.0", &available_versions)?;
116//! for version in newer {
117//! println!("Newer version: {}", version);
118//! }
119//!
120//! // Find the latest version
121//! if let Some(latest) = VersionComparator::get_latest(&available_versions)? {
122//! println!("Latest version: {}", latest);
123//! }
124//! # Ok(())
125//! # }
126//! ```
127//!
128//! # Prerelease Version Handling
129//!
130//! AGPM provides sophisticated prerelease version management:
131//!
132//! - **Default exclusion**: Most constraints exclude prereleases for stability
133//! - **Explicit inclusion**: Use Git refs to include them
134//! - **Constraint inheritance**: If any constraint allows prereleases, all do
135//! - **Version precedence**: Stable versions are preferred when available
136//!
137//! # Error Handling
138//!
139//! The version system provides comprehensive error handling:
140//!
141//! - **Invalid version strings**: Malformed semantic versions are rejected
142//! - **Conflicting constraints**: Impossible combinations are detected early
143//! - **Missing dependencies**: Required dependencies without versions are flagged
144//! - **Resolution failures**: Unsatisfiable constraints are clearly reported
145//!
146//! # Cross-References
147//!
148//! - For detailed constraint syntax and resolution: [`constraints`]
149//! - For version comparison utilities: [`comparison`]
150//! - For Git repository integration: [`crate::git`]
151//! - For dependency management: [`crate::resolver`]
152
153use crate::git::GitRepo;
154use anyhow::{Context, Result};
155use regex::Regex;
156use semver::{Version, VersionReq};
157use serde::{Deserialize, Serialize};
158use std::sync::Arc;
159
160/// Parse a version requirement string, normalizing 'v' prefixes.
161///
162/// This helper function provides centralized semver parsing that handles both
163/// prefixed (`v1.0.0`, `^v1.0.0`) and unprefixed (`1.0.0`, `^1.0.0`) version strings.
164///
165/// # Arguments
166///
167/// * `requirement` - Version requirement string (e.g., "^v1.0.0", "~2.1.0", ">=v1.0.0")
168///
169/// # Returns
170///
171/// A parsed `VersionReq` if the requirement is valid semver syntax.
172///
173/// # Examples
174///
175/// ```
176/// use agpm_cli::version::parse_version_req;
177///
178/// // All of these parse successfully:
179/// assert!(parse_version_req("1.0.0").is_ok());
180/// assert!(parse_version_req("v1.0.0").is_ok());
181/// assert!(parse_version_req("^1.0.0").is_ok());
182/// assert!(parse_version_req("^v1.0.0").is_ok());
183/// assert!(parse_version_req("~v2.1.0").is_ok());
184/// assert!(parse_version_req(">=v1.0.0").is_ok());
185/// ```
186pub fn parse_version_req(requirement: &str) -> Result<VersionReq, semver::Error> {
187 // Strip 'v' prefix from version requirements
188 // Handles patterns like: "v1.0.0", "^v1.0.0", "~v2.1.0", "=v1.0.0", ">=v1.0.0", etc.
189 // We match 'v' at the start OR after operators to avoid breaking prerelease tags
190 // like "1.0.0-dev.1" or branch names like "develop"
191
192 static RE: std::sync::LazyLock<Regex> =
193 std::sync::LazyLock::new(|| Regex::new(r"(^|[~^=><])v").unwrap());
194
195 let normalized = RE.replace_all(requirement, "$1");
196
197 VersionReq::parse(&normalized)
198}
199
200/// Splits a version string into an optional prefix and the version/constraint part.
201///
202/// This function extracts versioned prefixes from tag names and version constraints,
203/// enabling support for prefixed versioning schemes like `agents-v1.0.0` or `snippets-^v2.0.0`.
204/// The prefix can contain hyphens and is separated from the version by detecting where
205/// the version pattern begins.
206///
207/// # Algorithm
208///
209/// Scans left-to-right to find the first occurrence of:
210/// - Constraint operators: `^`, `~`, `=`, `<`, `>`, `!`, `*`
211/// - Version prefix: `v` followed immediately by a digit
212/// - Bare digit (start of version number)
213///
214/// Everything before this point (minus trailing `-`) becomes the prefix.
215///
216/// # Arguments
217///
218/// * `s` - The string to parse (tag name or version constraint)
219///
220/// # Returns
221///
222/// A tuple of `(Option<String>, &str)` where:
223/// - First element is the prefix (if any)
224/// - Second element is the version/constraint string
225///
226/// # Examples
227///
228/// ```
229/// use agpm_cli::version::split_prefix_and_version;
230///
231/// // Prefixed versions
232/// assert_eq!(
233/// split_prefix_and_version("agents-v1.0.0"),
234/// (Some("agents".to_string()), "v1.0.0")
235/// );
236/// assert_eq!(
237/// split_prefix_and_version("my-tool-^v2.0.0"),
238/// (Some("my-tool".to_string()), "^v2.0.0")
239/// );
240///
241/// // Unprefixed versions
242/// assert_eq!(split_prefix_and_version("v1.0.0"), (None, "v1.0.0"));
243/// assert_eq!(split_prefix_and_version("^1.0.0"), (None, "^1.0.0"));
244///
245/// // Edge cases
246/// assert_eq!(
247/// split_prefix_and_version("tool-v-v1.0.0"),
248/// (Some("tool-v".to_string()), "v1.0.0")
249/// );
250/// ```
251#[inline]
252pub fn split_prefix_and_version(s: &str) -> (Option<String>, &str) {
253 // Iterate through characters with their byte indices (O(n) single pass)
254 for (byte_idx, ch) in s.char_indices() {
255 // Check for constraint operators or wildcard
256 if "^~=<>!*".contains(ch) {
257 return split_at_index(s, byte_idx);
258 }
259
260 // Check for 'v' followed by digit (version prefix)
261 if ch == 'v' {
262 // Look ahead to check next character (O(1) operation)
263 if s[byte_idx..].chars().nth(1).is_some_and(|next| next.is_ascii_digit()) {
264 return split_at_index(s, byte_idx);
265 }
266 }
267
268 // Check for bare digit (start of version number)
269 // Only treat as version start if:
270 // 1. At position 0 (start of string), OR
271 // 2. Immediately after a hyphen delimiter
272 if ch.is_ascii_digit() {
273 // Check if at start or after hyphen (O(1) operation)
274 let is_version_start = byte_idx == 0 || s[..byte_idx].ends_with('-');
275
276 if is_version_start {
277 return split_at_index(s, byte_idx);
278 }
279 }
280 }
281
282 // No version pattern found, entire string is the version/constraint
283 (None, s)
284}
285
286/// Helper function to split string at an index, extracting prefix if present.
287#[inline]
288fn split_at_index(s: &str, i: usize) -> (Option<String>, &str) {
289 if i == 0 {
290 // Version starts at beginning, no prefix
291 (None, s)
292 } else {
293 // Extract prefix (remove trailing hyphen)
294 let prefix = s[..i].trim_end_matches('-');
295 // Treat empty prefix as None (handles cases like "-v1.0.0")
296 if prefix.is_empty() {
297 (None, &s[i..])
298 } else {
299 (Some(prefix.to_string()), &s[i..])
300 }
301 }
302}
303
304/// Version information extracted from a Git tag.
305///
306/// `VersionInfo` represents a successfully parsed semantic version from a Git tag,
307/// along with metadata about the original tag and prerelease status. This structure
308/// is used throughout the version resolution system to maintain the connection
309/// between semantic versions and their source Git references.
310///
311/// # Fields
312///
313/// - `prefix`: Optional prefix for monorepo-style versioning (e.g., `"agents"` in `agents-v1.0.0`)
314/// - `version`: The parsed semantic version
315/// - `tag`: The original Git tag string (may include prefixes like `v` or `agents-v`)
316/// - `prerelease`: Whether this version contains prerelease identifiers
317///
318/// # Examples
319///
320/// ```rust,no_run
321/// use agpm_cli::version::VersionInfo;
322/// use semver::Version;
323///
324/// // Standard version without prefix
325/// let info = VersionInfo {
326/// prefix: None,
327/// version: Version::parse("1.0.0-beta.1").unwrap(),
328/// tag: "v1.0.0-beta.1".to_string(),
329/// prerelease: true,
330/// };
331///
332/// assert_eq!(info.prefix, None);
333/// assert_eq!(info.version.major, 1);
334/// assert_eq!(info.tag, "v1.0.0-beta.1");
335/// assert!(info.prerelease);
336///
337/// // Prefixed version for monorepo
338/// let prefixed = VersionInfo {
339/// prefix: Some("agents".to_string()),
340/// version: Version::parse("2.0.0").unwrap(),
341/// tag: "agents-v2.0.0".to_string(),
342/// prerelease: false,
343/// };
344///
345/// assert_eq!(prefixed.prefix, Some("agents".to_string()));
346/// assert_eq!(prefixed.version.major, 2);
347/// ```
348#[derive(Debug, Clone)]
349pub struct VersionInfo {
350 /// Optional prefix for versioned namespaces (e.g., "agents", "snippets")
351 pub prefix: Option<String>,
352 /// The parsed semantic version
353 pub version: Version,
354 /// The original Git tag string
355 pub tag: String,
356 /// Whether this is a prerelease version (alpha, beta, rc, etc.)
357 pub prerelease: bool,
358}
359
360/// Resolves semantic versions from Git repository tags.
361///
362/// `VersionResolver` provides the core functionality for discovering, parsing, and
363/// resolving semantic versions from Git tags. It handles tag discovery, version
364/// parsing, constraint matching, and best-version selection.
365///
366/// # Tag Processing
367///
368/// The resolver automatically:
369/// - Fetches all tags from a Git repository
370/// - Normalizes tag names (removes `v` prefixes, handles common formats)
371/// - Parses valid semantic versions (skips invalid tags)
372/// - Sorts versions in descending order (newest first)
373/// - Categorizes versions as stable or prerelease
374///
375/// # Resolution Strategy
376///
377/// When resolving version constraints:
378/// 1. **Exact versions** are matched with or without `v` prefixes
379/// 2. **Semantic ranges** are applied using semver matching rules (e.g., `^1.0.0`, `~2.1.0`)
380/// 3. **Tag/branch names** are matched exactly as fallback (including "latest" - just a name)
381/// 4. **Prerelease filtering** is applied based on constraint type
382///
383/// # Examples
384///
385/// ## Creating from Git Repository
386///
387/// ```rust,no_run
388/// use agpm_cli::version::VersionResolver;
389/// use agpm_cli::git::GitRepo;
390/// use std::path::PathBuf;
391///
392/// # async fn example() -> anyhow::Result<()> {
393/// let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
394/// let resolver = VersionResolver::from_git_tags(&repo).await?;
395///
396/// println!("Found {} versions", resolver.list_all().len());
397/// # Ok(())
398/// # }
399/// ```
400///
401/// ## Version Resolution
402///
403/// ```rust,no_run
404/// # use agpm_cli::version::VersionResolver;
405/// # use agpm_cli::git::GitRepo;
406/// # use std::path::PathBuf;
407/// #
408/// # async fn example() -> anyhow::Result<()> {
409/// # let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
410/// # let resolver = VersionResolver::from_git_tags(&repo).await?;
411///
412/// // Resolve various constraint types
413/// if let Some(version) = resolver.resolve("^1.0.0")? {
414/// println!("Caret range resolved to: {} ({})", version.tag, version.version);
415/// }
416///
417/// if let Some(version) = resolver.resolve("v1.2.3")? {
418/// println!("Exact match: {}", version.tag);
419/// }
420/// # Ok(())
421/// # }
422/// ```
423pub struct VersionResolver {
424 versions: Vec<Arc<VersionInfo>>,
425}
426
427impl VersionResolver {
428 /// Create a new empty resolver with no versions.
429 ///
430 /// This constructor creates an empty resolver that contains no version information.
431 /// It's primarily useful for testing or as a starting point before adding versions
432 /// manually. For normal usage, prefer [`from_git_tags`](Self::from_git_tags) which
433 /// populates the resolver from a Git repository.
434 ///
435 /// # Examples
436 ///
437 /// ```rust,no_run
438 /// use agpm_cli::version::VersionResolver;
439 ///
440 /// let resolver = VersionResolver::new();
441 /// assert_eq!(resolver.list_all().len(), 0);
442 /// assert!(resolver.get_latest().is_none());
443 /// ```
444 #[must_use]
445 pub const fn new() -> Self {
446 Self {
447 versions: Vec::new(),
448 }
449 }
450
451 /// Create a resolver by discovering and parsing tags from a Git repository.
452 ///
453 /// This method performs the complete tag discovery and parsing workflow:
454 /// 1. **Fetch tags**: Retrieve all Git tags from the repository
455 /// 2. **Parse versions**: Attempt to parse each tag as a semantic version
456 /// 3. **Filter valid**: Keep only tags that parse successfully
457 /// 4. **Sort versions**: Order by semantic version (newest first)
458 /// 5. **Detect prereleases**: Identify versions with prerelease components
459 ///
460 /// # Arguments
461 ///
462 /// * `repo` - The [`GitRepo`] instance to discover tags from
463 ///
464 /// # Returns
465 ///
466 /// Returns `Ok(VersionResolver)` with parsed versions, or `Err` if Git
467 /// operations fail. Individual tag parsing failures are silently ignored.
468 ///
469 /// # Tag Parsing Rules
470 ///
471 /// - Common prefixes (`v`, `V`) are automatically stripped
472 /// - Invalid semantic versions are skipped (not included in resolver)
473 /// - Valid versions are sorted in descending order
474 /// - Prerelease status is detected from version components
475 ///
476 /// # Examples
477 ///
478 /// ```rust,no_run
479 /// use agpm_cli::version::VersionResolver;
480 /// use agpm_cli::git::GitRepo;
481 /// use std::path::PathBuf;
482 ///
483 /// # async fn example() -> anyhow::Result<()> {
484 /// let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
485 /// let resolver = VersionResolver::from_git_tags(&repo).await?;
486 ///
487 /// println!("Discovered {} valid versions", resolver.list_all().len());
488 ///
489 /// if let Some(latest) = resolver.get_latest() {
490 /// println!("Latest version: {} ({})", latest.tag, latest.version);
491 /// }
492 /// # Ok(())
493 /// # }
494 /// ```
495 ///
496 /// # Error Handling
497 ///
498 /// This method returns errors for Git operations (repository access, tag listing)
499 /// but handles individual tag parsing failures gracefully by skipping invalid tags.
500 pub async fn from_git_tags(repo: &GitRepo) -> Result<Self> {
501 let tags = repo.list_tags().await?;
502 let mut versions = Vec::new();
503
504 for tag in tags {
505 if let Ok((prefix, version)) = Self::parse_tag(&tag) {
506 versions.push(Arc::new(VersionInfo {
507 prefix,
508 version: version.clone(),
509 tag: tag.clone(),
510 prerelease: !version.pre.is_empty(),
511 }));
512 }
513 }
514
515 // Sort versions in descending order (newest first)
516 versions.sort_by(|a, b| b.version.cmp(&a.version));
517
518 Ok(Self {
519 versions,
520 })
521 }
522
523 /// Parse a Git tag string into an optional prefix and semantic version.
524 ///
525 /// This internal method handles the extraction of versioned prefixes and parsing of
526 /// Git tag strings into semantic versions. It supports both prefixed tags (e.g.,
527 /// `agents-v1.0.0`) and unprefixed tags (e.g., `v1.0.0`).
528 ///
529 /// # Parsing Process
530 ///
531 /// 1. **Prefix extraction**: Use `split_prefix_and_version()` to separate prefix from version
532 /// 2. **Version normalization**: Strip `v` or `V` prefixes from version string
533 /// 3. **Semantic parsing**: Parse the cleaned string as a semantic version
534 /// 4. **Error context**: Provide helpful error messages for parsing failures
535 ///
536 /// # Arguments
537 ///
538 /// * `tag` - The Git tag string to parse
539 ///
540 /// # Returns
541 ///
542 /// Returns `Ok((Option<String>, Version))` where:
543 /// - First element is the optional prefix
544 /// - Second element is the parsed semantic version
545 ///
546 /// Returns `Err` for invalid semantic versions.
547 ///
548 /// # Examples
549 ///
550 /// ```rust,no_run
551 /// use agpm_cli::version::VersionResolver;
552 /// use semver::Version;
553 ///
554 /// // These would all parse successfully (if the method were public)
555 /// // "v1.0.0" → (None, Version::new(1, 0, 0))
556 /// // "agents-v2.1.3" → (Some("agents"), Version::new(2, 1, 3))
557 /// // "my-tool-v1.5.0-beta.1" → (Some("my-tool"), Version with prerelease)
558 /// ```
559 ///
560 /// # Implementation Note
561 ///
562 /// This method is private and used internally by [`from_git_tags`](Self::from_git_tags)
563 /// during the tag discovery and parsing process.
564 fn parse_tag(tag: &str) -> Result<(Option<String>, Version)> {
565 // Extract prefix and version string
566 let (prefix, version_str) = split_prefix_and_version(tag);
567
568 // Remove common version prefixes from the version part
569 let cleaned = version_str.trim_start_matches('v').trim_start_matches('V');
570
571 // Parse semantic version
572 let version = Version::parse(cleaned)
573 .with_context(|| format!("Failed to parse version from tag: {tag}"))?;
574
575 Ok((prefix, version))
576 }
577
578 /// Resolve a version requirement string to a specific version from available tags.
579 ///
580 /// This method applies version constraint logic to find the best matching version
581 /// from the resolver's collection of parsed Git tags. It supports various constraint
582 /// formats and applies appropriate matching rules for each type.
583 ///
584 /// # Constraint Resolution Order
585 ///
586 /// 1. **Exact versions**: Direct semantic version matches (with/without `v` prefix)
587 /// 2. **Version requirements**: Semver ranges like `"^1.0.0"`, `"~1.2.0"`, `"*"`
588 /// 3. **Tag names**: Exact tag string matching as fallback
589 ///
590 /// # Arguments
591 ///
592 /// * `requirement` - The version constraint string to resolve
593 ///
594 /// # Returns
595 ///
596 /// Returns `Ok(Some(VersionInfo))` if a matching version is found, `Ok(None)`
597 /// if no version satisfies the requirement, or `Err` for invalid requirements.
598 ///
599 /// # Prerelease Handling
600 ///
601 /// - **Default behavior**: Prereleases are excluded from semver range matching
602 /// - **Explicit matches**: Direct version/tag matches include prereleases
603 ///
604 /// # Examples
605 ///
606 /// ```rust,no_run
607 /// use agpm_cli::version::VersionResolver;
608 /// use agpm_cli::git::GitRepo;
609 /// use std::path::PathBuf;
610 ///
611 /// # async fn example() -> anyhow::Result<()> {
612 /// let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
613 /// let resolver = VersionResolver::from_git_tags(&repo).await?;
614 ///
615 /// // Exact version matching
616 /// if let Some(version) = resolver.resolve("1.2.3")? {
617 /// println!("Found exact version: {}", version.tag);
618 /// }
619 ///
620 /// // Semver ranges
621 /// if let Some(version) = resolver.resolve("^1.0.0")? {
622 /// println!("Compatible version: {} ({})", version.tag, version.version);
623 /// }
624 ///
625 /// // Tag name matching
626 /// if let Some(version) = resolver.resolve("v1.0.0-beta.1")? {
627 /// println!("Tag match: {}", version.tag);
628 /// }
629 /// # Ok(())
630 /// # }
631 /// ```
632 ///
633 /// # Resolution Precedence
634 ///
635 /// When multiple versions could match:
636 /// - **Highest version wins**: Newer semantic versions are preferred
637 /// - **Stable over prerelease**: Stable versions preferred unless prereleases explicitly allowed
638 /// - **First match for tags**: Tag name matching returns the first occurrence
639 pub fn resolve(&self, requirement: &str) -> Result<Option<Arc<VersionInfo>>> {
640 // Extract prefix and version part (e.g., "agents-^v1.0.0" → (Some("agents"), "^v1.0.0"))
641 let (prefix, version_str) = split_prefix_and_version(requirement);
642
643 // Filter versions by prefix first
644 let matching_prefix: Vec<&Arc<VersionInfo>> =
645 self.versions.iter().filter(|v| v.prefix.as_ref() == prefix.as_ref()).collect();
646
647 // Try exact version match (with or without 'v' prefix)
648 if let Ok(exact_version) = Version::parse(version_str.trim_start_matches('v')) {
649 return Ok(matching_prefix
650 .iter()
651 .find(|v| v.version == exact_version)
652 .map(|&v| Arc::clone(v)));
653 }
654
655 // Try as semantic version requirement using centralized parser
656 if let Ok(req) = parse_version_req(version_str) {
657 return Ok(matching_prefix
658 .iter()
659 .filter(|v| !v.prerelease) // Exclude prereleases by default
660 .find(|v| req.matches(&v.version))
661 .map(|&v| Arc::clone(v)));
662 }
663
664 // Try exact tag match (full tag including prefix)
665 for version in &self.versions {
666 if version.tag == requirement {
667 return Ok(Some(Arc::clone(version)));
668 }
669 }
670
671 Ok(None)
672 }
673
674 /// Get the latest version including prereleases.
675 ///
676 /// This method returns the absolute newest version from the resolver's collection,
677 /// including prerelease versions. Since versions are sorted in descending order,
678 /// this simply returns the first version in the list.
679 ///
680 /// # Returns
681 ///
682 /// Returns `Some(VersionInfo)` with the highest version, or `None` if no versions
683 /// are available in the resolver.
684 ///
685 /// # Prerelease Inclusion
686 ///
687 /// Unlike [`get_latest_stable`](Self::get_latest_stable), this method includes
688 /// prerelease versions in consideration. If the highest version happens to be
689 /// a prerelease (e.g., `2.0.0-beta.1` when `1.9.0` is the latest stable),
690 /// the prerelease version will be returned.
691 ///
692 /// # Examples
693 ///
694 /// ```rust,no_run
695 /// use agpm_cli::version::VersionResolver;
696 /// use agpm_cli::git::GitRepo;
697 /// use std::path::PathBuf;
698 ///
699 /// # async fn example() -> anyhow::Result<()> {
700 /// let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
701 /// let resolver = VersionResolver::from_git_tags(&repo).await?;
702 ///
703 /// if let Some(latest) = resolver.get_latest() {
704 /// println!("Absolute latest: {} (prerelease: {})",
705 /// latest.tag, latest.prerelease);
706 /// } else {
707 /// println!("No versions found in repository");
708 /// }
709 /// # Ok(())
710 /// # }
711 /// ```
712 ///
713 /// # Use Cases
714 ///
715 /// This method is useful when:
716 /// - You want the cutting-edge version regardless of stability
717 /// - Implementing `latest-prerelease` constraint resolution
718 /// - Analyzing the most recent development activity
719 #[must_use]
720 pub fn get_latest(&self) -> Option<Arc<VersionInfo>> {
721 self.versions.first().map(Arc::clone)
722 }
723
724 /// Get the latest stable version excluding prereleases.
725 ///
726 /// This method finds the newest version that doesn't contain prerelease identifiers
727 /// (such as `-alpha`, `-beta`, `-rc`). It's the preferred method for production
728 /// environments where stability is prioritized over cutting-edge features.
729 ///
730 /// # Returns
731 ///
732 /// Returns `Some(VersionInfo)` with the highest stable version, or `None` if no
733 /// stable versions are available (only prereleases exist).
734 ///
735 /// # Stability Definition
736 ///
737 /// A version is considered stable if its prerelease component is empty. This means:
738 /// - `1.0.0` is stable
739 /// - `1.0.0-beta.1` is not stable (has prerelease suffix)
740 /// - `1.0.0+build.123` is stable (build metadata doesn't affect stability)
741 ///
742 /// # Examples
743 ///
744 /// ```rust,no_run
745 /// use agpm_cli::version::VersionResolver;
746 /// use agpm_cli::git::GitRepo;
747 /// use std::path::PathBuf;
748 ///
749 /// # async fn example() -> anyhow::Result<()> {
750 /// let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
751 /// let resolver = VersionResolver::from_git_tags(&repo).await?;
752 ///
753 /// match resolver.get_latest_stable() {
754 /// Some(stable) => {
755 /// println!("Latest stable version: {}", stable.tag);
756 /// assert!(!stable.prerelease); // Always false for stable versions
757 /// }
758 /// None => println!("No stable versions found (only prereleases available)"),
759 /// }
760 /// # Ok(())
761 /// # }
762 /// ```
763 ///
764 /// # Comparison with `get_latest()`
765 ///
766 /// ```rust,no_run
767 /// # use agpm_cli::version::VersionResolver;
768 /// # use agpm_cli::git::GitRepo;
769 /// # use std::path::PathBuf;
770 /// #
771 /// # async fn example() -> anyhow::Result<()> {
772 /// # let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
773 /// # let resolver = VersionResolver::from_git_tags(&repo).await?;
774 ///
775 /// let latest = resolver.get_latest();
776 /// let stable = resolver.get_latest_stable();
777 ///
778 /// // Latest might be a prerelease version
779 /// // Stable will always be a non-prerelease version (or None)
780 ///
781 /// if let (Some(l), Some(s)) = (latest, stable) {
782 /// if l.version > s.version {
783 /// println!("Newest version {} is a prerelease", l.tag);
784 /// println!("Latest stable version is {}", s.tag);
785 /// }
786 /// }
787 /// # Ok(())
788 /// # }
789 /// ```
790 ///
791 /// # Use Cases
792 ///
793 /// This method is ideal for:
794 /// - Production dependency resolution
795 /// - Implementing `"latest"` constraint resolution
796 /// - Default version selection in package managers
797 /// - Stable release identification
798 #[must_use]
799 pub fn get_latest_stable(&self) -> Option<Arc<VersionInfo>> {
800 self.versions.iter().find(|v| !v.prerelease).map(Arc::clone)
801 }
802
803 /// List all versions discovered from Git tags.
804 ///
805 /// This method returns a complete list of all successfully parsed versions from
806 /// the Git repository, including both stable and prerelease versions. The list
807 /// is sorted in descending order by semantic version (newest first).
808 ///
809 /// # Returns
810 ///
811 /// Returns `Vec<VersionInfo>` containing all parsed versions. The vector may be
812 /// empty if no valid semantic versions were found in the repository tags.
813 ///
814 /// # Sorting Order
815 ///
816 /// Versions are sorted by semantic version precedence in descending order:
817 /// - Higher major versions first (e.g., `2.0.0` before `1.9.0`)
818 /// - Higher minor versions within same major (e.g., `1.5.0` before `1.2.0`)
819 /// - Higher patch versions within same minor (e.g., `1.2.3` before `1.2.1`)
820 /// - Release versions before prereleases (e.g., `1.0.0` before `1.0.0-beta.1`)
821 ///
822 /// # Examples
823 ///
824 /// ```rust,no_run
825 /// use agpm_cli::version::VersionResolver;
826 /// use agpm_cli::git::GitRepo;
827 /// use std::path::PathBuf;
828 ///
829 /// # async fn example() -> anyhow::Result<()> {
830 /// let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
831 /// let resolver = VersionResolver::from_git_tags(&repo).await?;
832 ///
833 /// let all_versions = resolver.list_all();
834 /// println!("Found {} versions:", all_versions.len());
835 ///
836 /// for (i, version) in all_versions.iter().enumerate() {
837 /// let status = if version.prerelease { "prerelease" } else { "stable" };
838 /// println!(" {}. {} ({}) - {}", i + 1, version.tag, version.version, status);
839 /// }
840 /// # Ok(())
841 /// # }
842 /// ```
843 ///
844 /// ## Filtering and Analysis
845 ///
846 /// ```rust,no_run
847 /// # use agpm_cli::version::VersionResolver;
848 /// # use agpm_cli::git::GitRepo;
849 /// # use std::path::PathBuf;
850 /// #
851 /// # async fn example() -> anyhow::Result<()> {
852 /// # let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
853 /// # let resolver = VersionResolver::from_git_tags(&repo).await?;
854 ///
855 /// let all_versions = resolver.list_all();
856 ///
857 /// // Count prereleases vs stable
858 /// let prerelease_count = all_versions.iter().filter(|v| v.prerelease).count();
859 /// let stable_count = all_versions.len() - prerelease_count;
860 ///
861 /// println!("Stable versions: {}, Prereleases: {}", stable_count, prerelease_count);
862 ///
863 /// // Find versions in a specific range
864 /// let v1_versions: Vec<_> = all_versions.iter()
865 /// .filter(|v| v.version.major == 1)
866 /// .collect();
867 /// println!("Found {} versions in v1.x.x series", v1_versions.len());
868 /// # Ok(())
869 /// # }
870 /// ```
871 ///
872 /// # Use Cases
873 ///
874 /// This method is useful for:
875 /// - Version analysis and reporting
876 /// - Building version selection interfaces
877 /// - Debugging version resolution issues
878 /// - Implementing custom constraint logic
879 #[must_use]
880 pub fn list_all(&self) -> Vec<Arc<VersionInfo>> {
881 self.versions.clone()
882 }
883
884 /// List only stable versions excluding prereleases.
885 ///
886 /// This method filters the complete version list to include only versions without
887 /// prerelease components. It's useful for scenarios where you need to work with
888 /// production-ready versions only.
889 ///
890 /// # Returns
891 ///
892 /// Returns `Vec<VersionInfo>` containing only stable versions, sorted in descending
893 /// order. The vector may be empty if no stable versions exist (only prereleases).
894 ///
895 /// # Filtering Criteria
896 ///
897 /// A version is included if:
898 /// - Its prerelease component is empty (no `-alpha`, `-beta`, `-rc` suffixes)
899 /// - It parses as a valid semantic version
900 /// - It was successfully extracted from a Git tag
901 ///
902 /// # Examples
903 ///
904 /// ```rust,no_run
905 /// use agpm_cli::version::VersionResolver;
906 /// use agpm_cli::git::GitRepo;
907 /// use std::path::PathBuf;
908 ///
909 /// # async fn example() -> anyhow::Result<()> {
910 /// let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
911 /// let resolver = VersionResolver::from_git_tags(&repo).await?;
912 ///
913 /// let stable_versions = resolver.list_stable();
914 /// println!("Found {} stable versions:", stable_versions.len());
915 ///
916 /// for version in stable_versions {
917 /// println!(" {} ({})", version.tag, version.version);
918 /// assert!(!version.prerelease); // Guaranteed to be false
919 /// }
920 /// # Ok(())
921 /// # }
922 /// ```
923 ///
924 /// ## Comparison with All Versions
925 ///
926 /// ```rust,no_run
927 /// # use agpm_cli::version::VersionResolver;
928 /// # use agpm_cli::git::GitRepo;
929 /// # use std::path::PathBuf;
930 /// #
931 /// # async fn example() -> anyhow::Result<()> {
932 /// # let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
933 /// # let resolver = VersionResolver::from_git_tags(&repo).await?;
934 ///
935 /// let all_versions = resolver.list_all();
936 /// let stable_versions = resolver.list_stable();
937 ///
938 /// println!("Total versions: {}", all_versions.len());
939 /// println!("Stable versions: {}", stable_versions.len());
940 /// println!("Prerelease versions: {}", all_versions.len() - stable_versions.len());
941 ///
942 /// if stable_versions.len() < all_versions.len() {
943 /// println!("Repository contains prerelease versions");
944 /// }
945 /// # Ok(())
946 /// # }
947 /// ```
948 ///
949 /// # Use Cases
950 ///
951 /// This method is particularly useful for:
952 /// - Production environment version selection
953 /// - Conservative update strategies
954 /// - Compliance requirements that exclude prereleases
955 /// - User interfaces that hide development versions by default
956 #[must_use]
957 pub fn list_stable(&self) -> Vec<Arc<VersionInfo>> {
958 self.versions.iter().filter(|v| !v.prerelease).map(Arc::clone).collect()
959 }
960
961 /// Check if a specific version constraint can be resolved.
962 ///
963 /// This method tests whether a given version constraint string can be successfully
964 /// resolved against the available versions in this resolver. It's a convenience
965 /// method that combines resolution and existence checking.
966 ///
967 /// # Arguments
968 ///
969 /// * `version` - The version constraint string to test
970 ///
971 /// # Returns
972 ///
973 /// Returns `true` if the version constraint resolves to an actual version,
974 /// `false` if no matching version is found or if resolution fails.
975 ///
976 /// # Resolution Types Tested
977 ///
978 /// This method can verify existence of:
979 /// - **Exact versions**: `"1.0.0"`, `"v1.2.3"`
980 /// - **Version ranges**: `"^1.0.0"`, `"~1.2.0"`, `">=1.0.0"`
981 /// - **Tag/branch names**: Exact Git tag or branch matches (including "latest")
982 ///
983 /// # Examples
984 ///
985 /// ```rust,no_run
986 /// use agpm_cli::version::VersionResolver;
987 /// use agpm_cli::git::GitRepo;
988 /// use std::path::PathBuf;
989 ///
990 /// # async fn example() -> anyhow::Result<()> {
991 /// let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
992 /// let resolver = VersionResolver::from_git_tags(&repo).await?;
993 ///
994 /// // Check if specific versions exist
995 /// if resolver.has_version("1.0.0") {
996 /// println!("Version 1.0.0 is available");
997 /// }
998 ///
999 /// if resolver.has_version("^1.0.0") {
1000 /// println!("Compatible versions with 1.0.0 exist");
1001 /// }
1002 ///
1003 /// // This will likely return false unless you have this exact tag
1004 /// if resolver.has_version("v99.99.99") {
1005 /// println!("Unlikely version found!");
1006 /// } else {
1007 /// println!("Version 99.99.99 not found (as expected)");
1008 /// }
1009 /// # Ok(())
1010 /// # }
1011 /// ```
1012 ///
1013 /// ## Validation Before Resolution
1014 ///
1015 /// ```rust,no_run
1016 /// # use agpm_cli::version::VersionResolver;
1017 /// # use agpm_cli::git::GitRepo;
1018 /// # use std::path::PathBuf;
1019 /// #
1020 /// # async fn example() -> anyhow::Result<()> {
1021 /// # let repo = GitRepo::new(PathBuf::from("/path/to/repo"));
1022 /// # let resolver = VersionResolver::from_git_tags(&repo).await?;
1023 ///
1024 /// let constraint = "^2.0.0";
1025 ///
1026 /// if resolver.has_version(constraint) {
1027 /// // Safe to resolve - we know it will succeed
1028 /// let version = resolver.resolve(constraint)?.unwrap();
1029 /// println!("Resolved {} to {}", constraint, version.tag);
1030 /// } else {
1031 /// println!("No versions satisfy constraint: {}", constraint);
1032 /// }
1033 /// # Ok(())
1034 /// # }
1035 /// ```
1036 ///
1037 /// # Error Handling
1038 ///
1039 /// This method handles resolution errors gracefully by returning `false` rather
1040 /// than propagating errors. This makes it safe to use for validation without
1041 /// extensive error handling.
1042 ///
1043 /// # Use Cases
1044 ///
1045 /// This method is useful for:
1046 /// - Validating user input before processing
1047 /// - Pre-flight checks in dependency resolution
1048 /// - Conditional logic based on version availability
1049 /// - User interface validation and feedback
1050 #[must_use]
1051 pub fn has_version(&self, version: &str) -> bool {
1052 self.resolve(version).unwrap_or(None).is_some()
1053 }
1054}
1055
1056impl Default for VersionResolver {
1057 fn default() -> Self {
1058 Self::new()
1059 }
1060}
1061
1062/// Check if a version string satisfies a version requirement.
1063///
1064/// This utility function provides standalone version matching without requiring
1065/// a [`VersionResolver`] instance. It supports semantic version requirements and
1066/// special keywords for direct version-to-requirement comparison.
1067///
1068/// # Arguments
1069///
1070/// * `version` - The version string to test (supports `v` prefixes)
1071/// * `requirement` - The requirement string to match against
1072///
1073/// # Returns
1074///
1075/// Returns `Ok(true)` if the version satisfies the requirement, `Ok(false)` if it
1076/// doesn't match, or `Err` for invalid version/requirement strings.
1077///
1078/// # Supported Requirements
1079///
1080/// - **Special keywords**: `"*"` (wildcard, always matches)
1081/// - **Exact versions**: `"1.0.0"` (must match exactly)
1082/// - **Caret ranges**: `"^1.0.0"` (compatible within major version)
1083/// - **Tilde ranges**: `"~1.2.0"` (compatible within minor version)
1084/// - **Comparison ranges**: `">=1.0.0"`, `"<2.0.0"`
1085/// - **Complex ranges**: `">=1.0.0, <2.0.0"` (multiple constraints)
1086///
1087/// # Examples
1088///
1089/// ```rust,no_run
1090/// use agpm_cli::version::matches_requirement;
1091///
1092/// # fn example() -> anyhow::Result<()> {
1093/// // Exact version matching
1094/// assert!(matches_requirement("1.0.0", "1.0.0")?);
1095/// assert!(matches_requirement("v1.0.0", "1.0.0")?); // v prefix ignored
1096/// assert!(!matches_requirement("1.0.1", "1.0.0")?);
1097///
1098/// // Caret range matching (compatible within major version)
1099/// assert!(matches_requirement("1.2.3", "^1.0.0")?);
1100/// assert!(matches_requirement("1.9.9", "^1.0.0")?);
1101/// assert!(!matches_requirement("2.0.0", "^1.0.0")?); // Major version change
1102///
1103/// // Tilde range matching (compatible within minor version)
1104/// assert!(matches_requirement("1.2.5", "~1.2.0")?);
1105/// assert!(!matches_requirement("1.3.0", "~1.2.0")?); // Minor version change
1106///
1107/// // Comparison ranges
1108/// assert!(matches_requirement("1.5.0", ">=1.0.0")?);
1109/// assert!(!matches_requirement("0.9.0", ">=1.0.0")?);
1110///
1111/// // Wildcard
1112/// assert!(matches_requirement("any.version", "*")?);
1113/// # Ok(())
1114/// # }
1115/// ```
1116///
1117/// ## Complex Range Matching
1118///
1119/// ```rust,no_run
1120/// use agpm_cli::version::matches_requirement;
1121///
1122/// # fn example() -> anyhow::Result<()> {
1123/// // Multiple constraints
1124/// assert!(matches_requirement("1.5.0", ">=1.0.0, <2.0.0")?);
1125/// assert!(!matches_requirement("2.0.0", ">=1.0.0, <2.0.0")?);
1126///
1127/// // Pre-release handling
1128/// assert!(matches_requirement("1.0.0-beta.1", "^1.0.0-beta")?);
1129/// # Ok(())
1130/// # }
1131/// ```
1132///
1133/// # Version Prefix Handling
1134///
1135/// The function handles both namespace prefixes and `v` prefixes:
1136/// - `"v1.0.0"` is treated as `"1.0.0"`
1137/// - `"V2.1.3"` is treated as `"2.1.3"`
1138/// - `"agents-v1.2.0"` requires `"agents-^v1.0.0"` (prefixes must match)
1139/// - Unprefixed versions don't match prefixed requirements and vice versa
1140///
1141/// # Error Cases
1142///
1143/// This function returns errors for:
1144/// - Invalid semantic version strings
1145/// - Malformed requirement syntax
1146/// - Unparseable version ranges
1147///
1148/// # Use Cases
1149///
1150/// This function is useful for:
1151/// - Quick version compatibility checks
1152/// - Input validation in CLI tools
1153/// - Testing version constraints programmatically
1154/// - Implementing custom version resolution logic
1155pub fn matches_requirement(version: &str, requirement: &str) -> Result<bool> {
1156 // Extract prefixes from both version and requirement
1157 let (version_prefix, version_str) = split_prefix_and_version(version);
1158 let (req_prefix, req_str) = split_prefix_and_version(requirement);
1159
1160 // Ensure prefixes match (both None, or both Some with same value)
1161 if version_prefix != req_prefix {
1162 return Ok(false);
1163 }
1164
1165 // Handle wildcard in the version portion
1166 if req_str == "*" {
1167 return Ok(true);
1168 }
1169
1170 // Parse version (strip v prefix from version portion)
1171 let version = Version::parse(version_str.trim_start_matches('v'))?;
1172
1173 // Parse requirement (with v-prefix normalization)
1174 let req = parse_version_req(req_str)
1175 .map_err(|e| anyhow::anyhow!("Invalid version requirement '{requirement}': {e}"))?;
1176
1177 Ok(req.matches(&version))
1178}
1179
1180/// Parse a version constraint string into a structured constraint type.
1181///
1182/// This function analyzes a constraint string and determines whether it represents
1183/// a Git commit hash, a version/tag specification, or a branch name. It provides
1184/// a simple classification system for different types of version references.
1185///
1186/// # Classification Logic
1187///
1188/// The function uses heuristics to determine constraint types:
1189/// 1. **Commit hashes**: 7+ hexadecimal characters (e.g., `"abc123def"`)
1190/// 2. **Version/tag specs**: Valid semantic versions or requirements (e.g., `"^1.0.0"`, `"*"`)
1191/// 3. **Branch names**: Everything else (e.g., `"main"`, `"latest"`, `"feature/auth"`)
1192///
1193/// # Arguments
1194///
1195/// * `constraint` - The constraint string to parse and classify
1196///
1197/// # Returns
1198///
1199/// Returns a [`VersionConstraint`] enum variant indicating the constraint type:
1200/// - [`VersionConstraint::Commit`] for Git commit hashes
1201/// - [`VersionConstraint::Tag`] for semantic versions and requirements
1202/// - [`VersionConstraint::Branch`] for Git branch names
1203///
1204/// # Examples
1205///
1206/// ```rust,no_run
1207/// use agpm_cli::version::{parse_version_constraint, VersionConstraint};
1208///
1209/// // Semantic versions are classified as tags
1210/// let constraint = parse_version_constraint("1.0.0");
1211/// assert!(matches!(constraint, VersionConstraint::Tag(_)));
1212///
1213/// let constraint = parse_version_constraint("v1.2.3");
1214/// assert!(matches!(constraint, VersionConstraint::Tag(_)));
1215///
1216/// // Prefixed versions and constraints are also classified as tags
1217/// let constraint = parse_version_constraint("agents-v1.2.0");
1218/// assert!(matches!(constraint, VersionConstraint::Tag(_)));
1219///
1220/// let constraint = parse_version_constraint("agents-^v1.0.0");
1221/// assert!(matches!(constraint, VersionConstraint::Tag(_)));
1222///
1223/// // Version requirements are classified as tags
1224/// let constraint = parse_version_constraint("^1.0.0");
1225/// assert!(matches!(constraint, VersionConstraint::Tag(_)));
1226///
1227/// let constraint = parse_version_constraint("*");
1228/// assert!(matches!(constraint, VersionConstraint::Tag(_)));
1229///
1230/// // Commit hashes are detected by hex pattern
1231/// let constraint = parse_version_constraint("abc1234");
1232/// assert!(matches!(constraint, VersionConstraint::Commit(_)));
1233///
1234/// let constraint = parse_version_constraint("1234567890abcdef1234567890abcdef12345678");
1235/// assert!(matches!(constraint, VersionConstraint::Commit(_)));
1236///
1237/// // Branch names are the fallback
1238/// let constraint = parse_version_constraint("main");
1239/// assert!(matches!(constraint, VersionConstraint::Branch(_)));
1240///
1241/// let constraint = parse_version_constraint("feature/auth-system");
1242/// assert!(matches!(constraint, VersionConstraint::Branch(_)));
1243/// ```
1244///
1245/// # Commit Hash Detection
1246///
1247/// The function identifies commit hashes using these criteria:
1248/// - Minimum 7 characters (Git's default abbreviation length)
1249/// - All characters must be hexadecimal (0-9, a-f, A-F)
1250/// - No maximum length (supports full 40-character SHA-1 hashes)
1251///
1252/// # Version/Tag Detection
1253///
1254/// Version and tag specifications are identified by:
1255/// - Valid semantic version parsing (with or without `v` prefix)
1256/// - Valid semantic version requirement parsing (ranges, comparisons)
1257/// - Wildcard `"*"` for any version
1258///
1259/// # Branch Name Fallback
1260///
1261/// Any string that doesn't match the above patterns is treated as a branch name:
1262/// - Simple names: `"main"`, `"develop"`, `"staging"`, `"latest"`
1263/// - Namespaced branches: `"feature/new-ui"`, `"bugfix/auth-issue"`
1264/// - Special characters: `"release/v1.0"`, `"user/name/branch"`
1265///
1266/// # Use Cases
1267///
1268/// This function is useful for:
1269/// - Parsing user input in dependency specifications
1270/// - Routing version resolution to appropriate handlers
1271/// - Validating constraint syntax in configuration files
1272/// - Building version constraint objects from strings
1273#[must_use]
1274pub fn parse_version_constraint(constraint: &str) -> VersionConstraint {
1275 // Check if it looks like a commit hash (40 hex chars or abbreviated)
1276 if constraint.len() >= 7 && constraint.chars().all(|c| c.is_ascii_hexdigit()) {
1277 return VersionConstraint::Commit(constraint.to_string());
1278 }
1279
1280 // Extract prefix to check the version portion
1281 let (_prefix, version_str) = split_prefix_and_version(constraint);
1282
1283 // Check if the version portion is a semantic version or version requirement
1284 if Version::parse(version_str.trim_start_matches('v')).is_ok()
1285 || parse_version_req(version_str).is_ok()
1286 || version_str == "*"
1287 {
1288 return VersionConstraint::Tag(constraint.to_string());
1289 }
1290
1291 // Otherwise treat as branch
1292 VersionConstraint::Branch(constraint.to_string())
1293}
1294
1295/// Version comparison utilities and analysis functions.
1296///
1297/// The [`comparison`] module provides tools for analyzing and comparing semantic
1298/// versions, finding newer versions, and determining latest releases from version
1299/// collections. See the module documentation for detailed usage examples.
1300pub mod comparison;
1301
1302/// Version conflict detection and circular dependency detection.
1303///
1304/// The [`conflict`] module provides sophisticated conflict analysis for version
1305/// requirements, detecting incompatible version constraints and circular dependencies
1306/// in the dependency graph.
1307pub mod conflict;
1308
1309/// Version constraint parsing, sets, and resolution system.
1310///
1311/// The [`constraints`] module contains the core constraint management system for
1312/// AGPM, including constraint parsing, conflict detection, and multi-dependency
1313/// resolution. See the module documentation for comprehensive examples.
1314pub mod constraints;
1315
1316/// Represents different types of version constraints in AGPM.
1317///
1318/// `VersionConstraint` is a simple enum that categorizes version references into
1319/// three main types: Git tags (including semantic versions), Git branches, and
1320/// Git commit hashes. This classification helps AGPM route version resolution
1321/// to the appropriate handling logic.
1322///
1323/// # Variants
1324///
1325/// - [`Tag`](Self::Tag): Semantic versions, version requirements, and Git tags
1326/// - [`Branch`](Self::Branch): Git branch names and references
1327/// - [`Commit`](Self::Commit): Git commit hashes (full or abbreviated)
1328///
1329/// # Serialization
1330///
1331/// This enum implements [`Serialize`] and [`Deserialize`] for use in configuration
1332/// files and lockfiles, allowing version constraints to be persisted and restored.
1333///
1334/// # Examples
1335///
1336/// ```rust,no_run
1337/// use agpm_cli::version::VersionConstraint;
1338///
1339/// // Create different constraint types
1340/// let version = VersionConstraint::Tag("1.0.0".to_string());
1341/// let branch = VersionConstraint::Branch("main".to_string());
1342/// let commit = VersionConstraint::Commit("abc123def".to_string());
1343///
1344/// // Access the string value
1345/// assert_eq!(version.as_str(), "1.0.0");
1346/// assert_eq!(branch.as_str(), "main");
1347/// assert_eq!(commit.as_str(), "abc123def");
1348/// ```
1349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1350pub enum VersionConstraint {
1351 /// A semantic version tag (e.g., "v1.2.0", "1.0.0")
1352 Tag(String),
1353 /// A git branch reference (e.g., "main", "develop", "feature/new")
1354 Branch(String),
1355 /// A specific git commit hash (full or abbreviated)
1356 Commit(String),
1357}
1358
1359impl VersionConstraint {
1360 /// Get the string representation of this constraint.
1361 ///
1362 /// This method extracts the underlying string value from any constraint variant,
1363 /// providing a uniform way to access the constraint specification regardless
1364 /// of its type classification.
1365 ///
1366 /// # Returns
1367 ///
1368 /// Returns `&str` containing the original constraint string.
1369 ///
1370 /// # Examples
1371 ///
1372 /// ```rust,no_run
1373 /// use agpm_cli::version::VersionConstraint;
1374 ///
1375 /// let tag = VersionConstraint::Tag("^1.0.0".to_string());
1376 /// assert_eq!(tag.as_str(), "^1.0.0");
1377 ///
1378 /// let branch = VersionConstraint::Branch("feature/auth".to_string());
1379 /// assert_eq!(branch.as_str(), "feature/auth");
1380 ///
1381 /// let commit = VersionConstraint::Commit("abc123def456".to_string());
1382 /// assert_eq!(commit.as_str(), "abc123def456");
1383 /// ```
1384 ///
1385 /// # Use Cases
1386 ///
1387 /// This method is useful for:
1388 /// - Displaying constraints in user interfaces
1389 /// - Logging and debugging version resolution
1390 /// - Passing constraint strings to external tools
1391 /// - Serializing constraints to text formats
1392 #[must_use]
1393 pub fn as_str(&self) -> &str {
1394 match self {
1395 Self::Tag(s) => s,
1396 Self::Branch(s) => s,
1397 Self::Commit(s) => s,
1398 }
1399 }
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404 use super::*;
1405 use crate::test_utils::TestGit;
1406 use tempfile::TempDir;
1407
1408 fn create_test_repo_with_tags() -> (TempDir, GitRepo) {
1409 let temp_dir = TempDir::new().unwrap();
1410 let repo_path = temp_dir.path();
1411
1412 // Use TestGit helper instead of raw Command
1413 let git = TestGit::new(repo_path);
1414 git.init().unwrap();
1415 git.config_user().unwrap();
1416
1417 std::fs::write(repo_path.join("README.md"), "Test").unwrap();
1418
1419 git.add_all().unwrap();
1420 git.commit("Initial commit").unwrap();
1421
1422 let tags = vec!["v1.0.0", "v1.1.0", "v1.2.0", "v2.0.0-beta.1", "v2.0.0"];
1423 for tag in tags {
1424 git.tag(tag).unwrap();
1425 }
1426
1427 let repo = GitRepo::new(repo_path);
1428 (temp_dir, repo)
1429 }
1430
1431 #[tokio::test]
1432 async fn test_version_parsing() {
1433 let (_temp, repo) = create_test_repo_with_tags();
1434 let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
1435
1436 assert_eq!(resolver.versions.len(), 5);
1437 assert_eq!(resolver.get_latest().unwrap().tag, "v2.0.0");
1438 assert_eq!(resolver.get_latest_stable().unwrap().tag, "v2.0.0");
1439 }
1440
1441 #[tokio::test]
1442 async fn test_version_resolution() {
1443 let (_temp, repo) = create_test_repo_with_tags();
1444 let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
1445
1446 // Exact versions
1447 assert_eq!(resolver.resolve("1.1.0").unwrap().unwrap().tag, "v1.1.0");
1448 assert_eq!(resolver.resolve("v1.1.0").unwrap().unwrap().tag, "v1.1.0");
1449
1450 // Version constraints
1451 assert_eq!(resolver.resolve("^1.0.0").unwrap().unwrap().tag, "v1.2.0");
1452 assert_eq!(resolver.resolve("~1.1.0").unwrap().unwrap().tag, "v1.1.0");
1453 }
1454
1455 #[tokio::test]
1456 async fn test_has_version() {
1457 let (_temp, repo) = create_test_repo_with_tags();
1458 let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
1459
1460 // Exact versions
1461 assert!(resolver.has_version("v1.0.0"));
1462 assert!(resolver.has_version("1.0.0"));
1463
1464 // Non-existent versions (including "latest" - just a tag name)
1465 assert!(!resolver.has_version("v3.0.0"));
1466 assert!(!resolver.has_version("latest"));
1467 assert!(!resolver.has_version("latest-prerelease"));
1468 }
1469
1470 #[tokio::test]
1471 async fn test_matches_requirement() {
1472 // Unprefixed versions
1473 assert!(matches_requirement("1.2.0", "^1.0.0").unwrap());
1474 assert!(matches_requirement("v1.2.0", "^1.0.0").unwrap());
1475 assert!(!matches_requirement("2.0.0", "^1.0.0").unwrap());
1476 assert!(matches_requirement("any.version", "*").unwrap());
1477 }
1478
1479 #[test]
1480 fn test_matches_requirement_with_prefixes() {
1481 // Prefixed versions with matching prefixes
1482 assert!(matches_requirement("agents-v1.2.0", "agents-^v1.0.0").unwrap());
1483 assert!(matches_requirement("agents-v1.2.5", "agents-~v1.2.0").unwrap());
1484 assert!(matches_requirement("tool123-v2.0.0", "tool123->=v1.0.0").unwrap());
1485
1486 // Prefixed versions with wildcard
1487 assert!(matches_requirement("agents-v1.2.0", "agents-*").unwrap());
1488
1489 // Prefixed version doesn't match unprefixed requirement
1490 assert!(!matches_requirement("agents-v1.2.0", "^v1.0.0").unwrap());
1491
1492 // Unprefixed version doesn't match prefixed requirement
1493 assert!(!matches_requirement("v1.2.0", "agents-^v1.0.0").unwrap());
1494
1495 // Different prefixes don't match
1496 assert!(!matches_requirement("agents-v1.2.0", "snippets-^v1.0.0").unwrap());
1497 assert!(!matches_requirement("tool-v1.0.0", "agent-v1.0.0").unwrap());
1498
1499 // Prefixed version doesn't satisfy constraint
1500 assert!(!matches_requirement("agents-v2.0.0", "agents-^v1.0.0").unwrap());
1501 }
1502
1503 #[test]
1504 fn test_parse_version_constraint() {
1505 // Unprefixed constraints
1506 assert_eq!(
1507 parse_version_constraint("v1.0.0"),
1508 VersionConstraint::Tag("v1.0.0".to_string())
1509 );
1510 assert_eq!(
1511 parse_version_constraint("^1.0.0"),
1512 VersionConstraint::Tag("^1.0.0".to_string())
1513 );
1514 assert_eq!(parse_version_constraint("*"), VersionConstraint::Tag("*".to_string()));
1515 assert_eq!(parse_version_constraint("main"), VersionConstraint::Branch("main".to_string()));
1516 assert_eq!(
1517 parse_version_constraint("latest"),
1518 VersionConstraint::Branch("latest".to_string())
1519 );
1520 assert_eq!(
1521 parse_version_constraint("latest-prerelease"),
1522 VersionConstraint::Branch("latest-prerelease".to_string())
1523 );
1524 assert_eq!(
1525 parse_version_constraint("feature/test"),
1526 VersionConstraint::Branch("feature/test".to_string())
1527 );
1528 assert_eq!(
1529 parse_version_constraint("abc1234"),
1530 VersionConstraint::Commit("abc1234".to_string())
1531 );
1532 assert_eq!(
1533 parse_version_constraint("1234567890abcdef"),
1534 VersionConstraint::Commit("1234567890abcdef".to_string())
1535 );
1536
1537 // Prefixed constraints - all should be Tag
1538 assert_eq!(
1539 parse_version_constraint("agents-v1.2.0"),
1540 VersionConstraint::Tag("agents-v1.2.0".to_string())
1541 );
1542 assert_eq!(
1543 parse_version_constraint("agents-^v1.0.0"),
1544 VersionConstraint::Tag("agents-^v1.0.0".to_string())
1545 );
1546 assert_eq!(
1547 parse_version_constraint("snippets-~v2.0.0"),
1548 VersionConstraint::Tag("snippets-~v2.0.0".to_string())
1549 );
1550 assert_eq!(
1551 parse_version_constraint("tool123-*"),
1552 VersionConstraint::Tag("tool123-*".to_string())
1553 );
1554 assert_eq!(
1555 parse_version_constraint("my-cool-tool->=v1.0.0"),
1556 VersionConstraint::Tag("my-cool-tool->=v1.0.0".to_string())
1557 );
1558
1559 // Prefixed branches should still be Branch
1560 assert_eq!(
1561 parse_version_constraint("agents-main"),
1562 VersionConstraint::Branch("agents-main".to_string())
1563 );
1564 }
1565
1566 #[tokio::test]
1567 async fn test_version_list_all() {
1568 let (_temp, repo) = create_test_repo_with_tags();
1569 let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
1570
1571 let all_versions = resolver.list_all();
1572 assert_eq!(all_versions.len(), 5);
1573
1574 // Should be sorted in descending order
1575 assert_eq!(all_versions[0].tag, "v2.0.0");
1576 assert_eq!(all_versions[1].tag, "v2.0.0-beta.1");
1577 }
1578
1579 #[tokio::test]
1580 async fn test_version_list_stable() {
1581 let (_temp, repo) = create_test_repo_with_tags();
1582 let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
1583
1584 let stable_versions = resolver.list_stable();
1585 assert_eq!(stable_versions.len(), 4); // No beta versions
1586
1587 for version in stable_versions {
1588 assert!(!version.prerelease);
1589 }
1590 }
1591
1592 // ========== Prefix Support Tests ==========
1593
1594 #[test]
1595 fn test_split_prefix_and_version() {
1596 // Prefixed versions
1597 assert_eq!(
1598 split_prefix_and_version("agents-v1.0.0"),
1599 (Some("agents".to_string()), "v1.0.0")
1600 );
1601 assert_eq!(
1602 split_prefix_and_version("agents-^v1.0.0"),
1603 (Some("agents".to_string()), "^v1.0.0")
1604 );
1605 assert_eq!(
1606 split_prefix_and_version("my-cool-agent-v2.0.0"),
1607 (Some("my-cool-agent".to_string()), "v2.0.0")
1608 );
1609
1610 // Unprefixed versions
1611 assert_eq!(split_prefix_and_version("v1.0.0"), (None, "v1.0.0"));
1612 assert_eq!(split_prefix_and_version("^v1.0.0"), (None, "^v1.0.0"));
1613 assert_eq!(split_prefix_and_version("1.0.0"), (None, "1.0.0"));
1614
1615 // Edge cases
1616 assert_eq!(
1617 split_prefix_and_version("tool-v-v1.0.0"),
1618 (Some("tool-v".to_string()), "v1.0.0")
1619 );
1620 assert_eq!(split_prefix_and_version("a-b-c-v1.0.0"), (Some("a-b-c".to_string()), "v1.0.0"));
1621 assert_eq!(
1622 split_prefix_and_version("prefix-~1.0.0"),
1623 (Some("prefix".to_string()), "~1.0.0")
1624 );
1625 }
1626
1627 #[test]
1628 fn test_split_prefix_edge_cases() {
1629 // Empty prefix - should be treated as None
1630 assert_eq!(split_prefix_and_version("-v1.0.0"), (None, "v1.0.0"));
1631 assert_eq!(split_prefix_and_version("--v1.0.0"), (None, "v1.0.0"));
1632
1633 // Prefix with numbers - digits in middle of prefix are preserved
1634 assert_eq!(
1635 split_prefix_and_version("tool123-v1.0.0"),
1636 (Some("tool123".to_string()), "v1.0.0")
1637 );
1638 assert_eq!(
1639 split_prefix_and_version("agent2-v1.0.0"),
1640 (Some("agent2".to_string()), "v1.0.0")
1641 );
1642 // Digit after hyphen is treated as version start
1643 assert_eq!(split_prefix_and_version("tool-123"), (Some("tool".to_string()), "123"));
1644 // 'v' followed by digit takes precedence
1645 assert_eq!(
1646 split_prefix_and_version("abc-v2-agent-v1.0.0"),
1647 (Some("abc".to_string()), "v2-agent-v1.0.0")
1648 );
1649
1650 // Very long prefix (stress test)
1651 let long_prefix = "a".repeat(100);
1652 let tag = format!("{}-v1.0.0", long_prefix);
1653 let (prefix, version) = split_prefix_and_version(&tag);
1654 assert_eq!(prefix, Some(long_prefix));
1655 assert_eq!(version, "v1.0.0");
1656
1657 // Unicode in prefixes - note: 'v' followed by digit is detected as version
1658 assert_eq!(
1659 split_prefix_and_version("агенты-v1.0.0"),
1660 (Some("агенты".to_string()), "v1.0.0")
1661 );
1662 assert_eq!(split_prefix_and_version("工具-v1.0.0"), (Some("工具".to_string()), "v1.0.0"));
1663 // Unicode prefixes without version pattern
1664 assert_eq!(split_prefix_and_version("агенты-2.0.0"), (Some("агенты".to_string()), "2.0.0"));
1665
1666 // String ending with 'v' (tests panic fix)
1667 assert_eq!(split_prefix_and_version("prefix-v"), (None, "prefix-v"));
1668 assert_eq!(split_prefix_and_version("v"), (None, "v"));
1669
1670 // Multiple hyphens
1671 assert_eq!(
1672 split_prefix_and_version("my-cool-tool-v1.0.0"),
1673 (Some("my-cool-tool".to_string()), "v1.0.0")
1674 );
1675 }
1676
1677 fn create_test_repo_with_prefixed_tags() -> (TempDir, GitRepo) {
1678 let temp_dir = TempDir::new().unwrap();
1679 let repo_path = temp_dir.path();
1680
1681 // Use TestGit helper instead of raw Command
1682 let git = TestGit::new(repo_path);
1683 git.init().unwrap();
1684 git.config_user().unwrap();
1685
1686 std::fs::write(repo_path.join("README.md"), "Test").unwrap();
1687
1688 git.add_all().unwrap();
1689 git.commit("Initial commit").unwrap();
1690
1691 // Create tags with different prefixes
1692 let tags = vec![
1693 "agents-v1.0.0",
1694 "agents-v1.2.0",
1695 "agents-v2.0.0",
1696 "snippets-v1.0.0",
1697 "snippets-v1.5.0",
1698 "v1.0.0", // Unprefixed
1699 "v2.0.0", // Unprefixed
1700 ];
1701 for tag in tags {
1702 git.tag(tag).unwrap();
1703 }
1704
1705 let repo = GitRepo::new(repo_path);
1706 (temp_dir, repo)
1707 }
1708
1709 #[tokio::test]
1710 async fn test_prefixed_version_parsing() {
1711 let (_temp, repo) = create_test_repo_with_prefixed_tags();
1712 let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
1713
1714 // Should parse all 7 tags
1715 assert_eq!(resolver.versions.len(), 7);
1716
1717 // Check prefixes are correctly extracted
1718 let agents_versions: Vec<_> =
1719 resolver.versions.iter().filter(|v| v.prefix == Some("agents".to_string())).collect();
1720 assert_eq!(agents_versions.len(), 3);
1721
1722 let snippets_versions: Vec<_> =
1723 resolver.versions.iter().filter(|v| v.prefix == Some("snippets".to_string())).collect();
1724 assert_eq!(snippets_versions.len(), 2);
1725
1726 let unprefixed_versions: Vec<_> =
1727 resolver.versions.iter().filter(|v| v.prefix.is_none()).collect();
1728 assert_eq!(unprefixed_versions.len(), 2);
1729 }
1730
1731 #[tokio::test]
1732 async fn test_prefixed_version_resolution() {
1733 let (_temp, repo) = create_test_repo_with_prefixed_tags();
1734 let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
1735
1736 // Prefixed exact version
1737 let result = resolver.resolve("agents-v1.2.0").unwrap().unwrap();
1738 assert_eq!(result.tag, "agents-v1.2.0");
1739 assert_eq!(result.prefix, Some("agents".to_string()));
1740
1741 // Prefixed constraint - should match highest agents version
1742 let result = resolver.resolve("agents-^v1.0.0").unwrap().unwrap();
1743 assert_eq!(result.tag, "agents-v1.2.0");
1744 assert_eq!(result.prefix, Some("agents".to_string()));
1745
1746 // Different prefix constraint
1747 let result = resolver.resolve("snippets-^v1.0.0").unwrap().unwrap();
1748 assert_eq!(result.tag, "snippets-v1.5.0");
1749 assert_eq!(result.prefix, Some("snippets".to_string()));
1750
1751 // Unprefixed constraint should only match unprefixed tags
1752 let result = resolver.resolve("^v1.0.0").unwrap().unwrap();
1753 assert_eq!(result.tag, "v1.0.0");
1754 assert_eq!(result.prefix, None);
1755 }
1756
1757 #[tokio::test]
1758 async fn test_prefix_isolation() {
1759 let (_temp, repo) = create_test_repo_with_prefixed_tags();
1760 let resolver = VersionResolver::from_git_tags(&repo).await.unwrap();
1761
1762 // agents-^v1.0.0 should NOT match snippets-v1.5.0 even though 1.5.0 > 1.0.0
1763 let result = resolver.resolve("agents-^v1.0.0").unwrap().unwrap();
1764 assert_eq!(result.prefix, Some("agents".to_string()));
1765 assert_ne!(result.tag, "snippets-v1.5.0");
1766
1767 // Unprefixed constraint should NOT match prefixed tags
1768 let result = resolver.resolve("^v1.0.0").unwrap().unwrap();
1769 assert_eq!(result.prefix, None);
1770 assert!(!result.tag.contains("agents-"));
1771 assert!(!result.tag.contains("snippets-"));
1772 }
1773
1774 #[test]
1775 fn test_parse_version_req_with_prefix() -> anyhow::Result<()> {
1776 // The parse_version_req function should work on the version part only
1777 parse_version_req("^1.0.0")?;
1778 parse_version_req("^v1.0.0")?;
1779 parse_version_req("~2.1.0")?;
1780 parse_version_req(">=1.0.0")?;
1781 Ok(())
1782 }
1783}