agpm_cli/resolver/
version_resolution.rs

1//! Version constraint resolution helpers for the dependency resolver.
2//!
3//! This module provides utilities to bridge between version constraints
4//! (like `^1.0.0`, `~1.2.0`) and actual Git tags in repositories.
5
6use anyhow::Result;
7use semver::Version;
8
9use crate::version::constraints::{ConstraintSet, VersionConstraint};
10
11/// Checks if a string represents a version constraint rather than a direct reference.
12///
13/// Version constraints contain operators like `^`, `~`, `>`, `<`, `=`, or special
14/// keywords. Direct references are branch names, tag names, or commit hashes.
15/// This function now supports prefixed constraints like `agents-^v1.0.0`.
16///
17/// # Arguments
18///
19/// * `version` - The version string to check
20///
21/// # Returns
22///
23/// Returns `true` if the string contains constraint operators or keywords,
24/// `false` for plain tags, branches, or commit hashes.
25///
26/// # Examples
27///
28/// ```
29/// use agpm_cli::resolver::version_resolution::is_version_constraint;
30/// assert!(is_version_constraint("^1.0.0"));
31/// assert!(is_version_constraint("~1.2.0"));
32/// assert!(is_version_constraint(">=1.0.0"));
33/// assert!(is_version_constraint("*"));
34/// assert!(is_version_constraint("agents-^v1.0.0")); // Prefixed constraint
35/// assert!(is_version_constraint("agents-*")); // Prefixed wildcard
36/// assert!(!is_version_constraint("v1.0.0"));
37/// assert!(!is_version_constraint("agents-v1.0.0")); // Exact prefixed tag
38/// assert!(!is_version_constraint("main"));
39/// assert!(!is_version_constraint("abc123def"));
40/// ```
41#[must_use]
42pub fn is_version_constraint(version: &str) -> bool {
43    // Extract prefix first, then check the version part for constraint indicators
44    let (_prefix, version_str) = crate::version::split_prefix_and_version(version);
45
46    // Check for wildcard (works with or without prefix)
47    if version_str == "*" {
48        return true;
49    }
50
51    // Check for version constraint operators in the version part
52    if version_str.starts_with('^')
53        || version_str.starts_with('~')
54        || version_str.starts_with('>')
55        || version_str.starts_with('<')
56        || version_str.starts_with('=')
57        || version_str.contains(',')
58    // Range constraints like ">=1.0.0, <2.0.0"
59    {
60        return true;
61    }
62
63    false
64}
65
66/// Parses Git tags into semantic versions, filtering out non-semver tags.
67///
68/// This function handles both prefixed and non-prefixed version tags,
69/// including support for monorepo-style prefixes like `agents-v1.0.0`.
70/// Tags that don't represent valid semantic versions are filtered out.
71///
72/// # Arguments
73///
74/// * `tags` - List of Git tag names from the repository
75///
76/// # Returns
77///
78/// A vector of tuples containing the original tag name and its parsed Version,
79/// sorted by version (highest first). Tags with different prefixes are treated
80/// independently and sorted only by their semver portions.
81///
82/// # Examples
83///
84/// ```
85/// use agpm_cli::resolver::version_resolution::parse_tags_to_versions;
86/// let tags = vec![
87///     "v1.0.0".to_string(),
88///     "agents-v2.0.0".to_string(),
89///     "1.2.0".to_string(),
90///     "feature-branch".to_string(),
91///     "agents-v2.0.0-beta.1".to_string()
92/// ];
93/// let versions = parse_tags_to_versions(tags);
94/// // Returns: [("agents-v2.0.0", Version), ("agents-v2.0.0-beta.1", Version), ("1.2.0", Version), ("v1.0.0", Version)]
95/// ```
96#[must_use]
97pub fn parse_tags_to_versions(tags: Vec<String>) -> Vec<(String, Version)> {
98    let mut versions = Vec::new();
99
100    for tag in tags {
101        // Extract prefix and version part (handles both prefixed and unprefixed)
102        let (_prefix, version_str) = crate::version::split_prefix_and_version(&tag);
103
104        // Strip 'v' prefix from version part
105        let cleaned = version_str.trim_start_matches('v').trim_start_matches('V');
106
107        if let Ok(version) = Version::parse(cleaned) {
108            versions.push((tag, version));
109        }
110    }
111
112    // Sort by version, highest first
113    versions.sort_by(|a, b| b.1.cmp(&a.1));
114
115    versions
116}
117
118/// Finds the best matching tag for a version constraint.
119///
120/// This function resolves version constraints to actual Git tags by:
121/// 1. Extracting the prefix from the constraint (if any)
122/// 2. Filtering tags to only those with matching prefix
123/// 3. Parsing the constraint and matching tags
124/// 4. Selecting the best match (usually the highest compatible version)
125///
126/// # Arguments
127///
128/// * `constraint_str` - The version constraint string (e.g., "^1.0.0", "agents-^v1.0.0")
129/// * `tags` - List of Git tags from the repository
130///
131/// # Returns
132///
133/// Returns the best matching tag name, or an error if no tag satisfies the constraint.
134///
135/// # Examples
136///
137/// ```no_run
138/// # use anyhow::Result;
139/// # fn example() -> Result<()> {
140/// use agpm_cli::resolver::version_resolution::find_best_matching_tag;
141///
142/// // Unprefixed constraint
143/// let tags = vec!["v1.0.0".to_string(), "v1.2.0".to_string(), "v1.5.0".to_string(), "v2.0.0".to_string()];
144/// let best = find_best_matching_tag("^1.0.0", tags)?;
145/// assert_eq!(best, "v1.5.0"); // Highest 1.x.x version
146///
147/// // Prefixed constraint (monorepo)
148/// let tags = vec!["agents-v1.0.0".to_string(), "agents-v1.2.0".to_string(), "snippets-v1.0.0".to_string()];
149/// let best = find_best_matching_tag("agents-^v1.0.0", tags)?;
150/// assert_eq!(best, "agents-v1.2.0"); // Highest agents 1.x.x version
151/// # Ok(())
152/// # }
153/// ```
154pub fn find_best_matching_tag(constraint_str: &str, tags: Vec<String>) -> Result<String> {
155    // Extract prefix from constraint
156    let (constraint_prefix, version_str) = crate::version::split_prefix_and_version(constraint_str);
157
158    // Filter tags by prefix first
159    let filtered_tags: Vec<String> = tags
160        .into_iter()
161        .filter(|tag| {
162            let (tag_prefix, _) = crate::version::split_prefix_and_version(tag);
163            tag_prefix.as_ref() == constraint_prefix.as_ref()
164        })
165        .collect();
166
167    if filtered_tags.is_empty() {
168        return Err(anyhow::anyhow!(
169            "No tags found with matching prefix for constraint: {constraint_str}"
170        ));
171    }
172
173    // Parse filtered tags to versions
174    let tag_versions = parse_tags_to_versions(filtered_tags);
175
176    if tag_versions.is_empty() {
177        return Err(anyhow::anyhow!(
178            "No valid semantic version tags found for constraint: {constraint_str}"
179        ));
180    }
181
182    // Special case: wildcard (*) matches the highest available version
183    if version_str == "*" {
184        // tag_versions is already sorted highest first
185        return Ok(tag_versions[0].0.clone());
186    }
187
188    // Parse constraint using ONLY the version part (prefix already filtered)
189    // This ensures semver matching works correctly after prefix filtering
190    let constraint = VersionConstraint::parse(version_str)?;
191
192    // Extract just the versions for constraint matching
193    let versions: Vec<Version> = tag_versions.iter().map(|(_, v)| v.clone()).collect();
194
195    // Create a constraint set with just this constraint
196    let mut constraint_set = ConstraintSet::new();
197    constraint_set.add(constraint)?;
198
199    // Find the best match
200    if let Some(best_version) = constraint_set.find_best_match(&versions) {
201        // Find the original tag name for this version
202        for (tag_name, version) in tag_versions {
203            if &version == best_version {
204                return Ok(tag_name);
205            }
206        }
207    }
208
209    Err(anyhow::anyhow!("No tag found matching constraint: {constraint_str}"))
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_is_version_constraint() {
218        // Constraints
219        assert!(is_version_constraint("^1.0.0"));
220        assert!(is_version_constraint("~1.2.0"));
221        assert!(is_version_constraint(">=1.0.0"));
222        assert!(is_version_constraint("<2.0.0"));
223        assert!(is_version_constraint(">=1.0.0, <2.0.0"));
224        assert!(is_version_constraint("*"));
225
226        // Not constraints (including "latest" - it's just a tag name)
227        assert!(!is_version_constraint("v1.0.0"));
228        assert!(!is_version_constraint("1.0.0"));
229        assert!(!is_version_constraint("latest"));
230        assert!(!is_version_constraint("latest-prerelease"));
231        assert!(!is_version_constraint("main"));
232        assert!(!is_version_constraint("develop"));
233        assert!(!is_version_constraint("abc123def"));
234        assert!(!is_version_constraint("feature/auth"));
235    }
236
237    #[test]
238    fn test_parse_tags_to_versions() {
239        let tags = vec![
240            "v1.0.0".to_string(),
241            "1.2.0".to_string(),
242            "v2.0.0-beta.1".to_string(),
243            "main".to_string(),
244            "feature-branch".to_string(),
245            "v1.5.0".to_string(),
246        ];
247
248        let versions = parse_tags_to_versions(tags);
249
250        assert_eq!(versions.len(), 4);
251        assert_eq!(versions[0].0, "v2.0.0-beta.1");
252        assert_eq!(versions[1].0, "v1.5.0");
253        assert_eq!(versions[2].0, "1.2.0");
254        assert_eq!(versions[3].0, "v1.0.0");
255    }
256
257    #[test]
258    fn test_find_best_matching_tag() {
259        let tags = vec![
260            "v1.0.0".to_string(),
261            "v1.2.0".to_string(),
262            "v1.5.0".to_string(),
263            "v2.0.0".to_string(),
264            "v2.1.0".to_string(),
265        ];
266
267        // Test caret constraint
268        let result = find_best_matching_tag("^1.0.0", tags.clone()).unwrap();
269        assert_eq!(result, "v1.5.0");
270
271        // Test tilde constraint
272        let result = find_best_matching_tag("~1.2.0", tags.clone()).unwrap();
273        assert_eq!(result, "v1.2.0");
274
275        // Test greater than or equal
276        let result = find_best_matching_tag(">=2.0.0", tags.clone()).unwrap();
277        assert_eq!(result, "v2.1.0");
278    }
279
280    #[test]
281    fn test_find_best_matching_tag_no_match() {
282        let tags = vec!["v1.0.0".to_string(), "v2.0.0".to_string()];
283
284        let result = find_best_matching_tag("^3.0.0", tags);
285        assert!(result.is_err());
286        assert!(result.unwrap_err().to_string().contains("No tag found matching"));
287    }
288
289    #[test]
290    fn test_wildcard_matches_highest_version() {
291        let tags = vec![
292            "v1.0.0".to_string(),
293            "v1.2.0".to_string(),
294            "v2.0.0".to_string(),
295            "v1.5.0".to_string(),
296        ];
297
298        let result = find_best_matching_tag("*", tags).unwrap();
299        assert_eq!(result, "v2.0.0", "Wildcard should match highest version");
300    }
301
302    #[test]
303    fn test_prefixed_wildcard_matches_highest_in_namespace() {
304        let tags = vec![
305            "agents-v1.0.0".to_string(),
306            "agents-v1.2.0".to_string(),
307            "agents-v2.0.0".to_string(),
308            "snippets-v3.0.0".to_string(), // Higher but different prefix
309            "v5.0.0".to_string(),          // Higher but no prefix
310        ];
311
312        let result = find_best_matching_tag("agents-*", tags).unwrap();
313        assert_eq!(
314            result, "agents-v2.0.0",
315            "Prefixed wildcard should match highest in that prefix namespace"
316        );
317    }
318
319    // ========== Prefix Support Tests ==========
320
321    #[test]
322    fn test_is_version_constraint_with_prefix() {
323        // Prefixed constraints
324        assert!(is_version_constraint("agents-^v1.0.0"));
325        assert!(is_version_constraint("snippets-~v2.0.0"));
326        assert!(is_version_constraint("my-tool->=v1.0.0"));
327
328        // Prefixed wildcards (critical fix)
329        assert!(is_version_constraint("agents-*"));
330        assert!(is_version_constraint("snippets-*"));
331        assert!(is_version_constraint("my-tool-*"));
332
333        // Prefixed exact versions are NOT constraints
334        assert!(!is_version_constraint("agents-v1.0.0"));
335        assert!(!is_version_constraint("snippets-v2.0.0"));
336    }
337
338    #[test]
339    fn test_prefixed_wildcards_constraint_detection() {
340        // Regression test for prefixed wildcards bug
341        assert!(is_version_constraint("*"));
342        assert!(is_version_constraint("agents-*"));
343        assert!(is_version_constraint("tool123-*"));
344        assert!(is_version_constraint("my-cool-tool-*"));
345
346        // Ensure these are still NOT constraints
347        assert!(!is_version_constraint("agents-v1.0.0"));
348        assert!(!is_version_constraint("main"));
349        assert!(!is_version_constraint("develop"));
350    }
351
352    #[test]
353    fn test_parse_tags_to_versions_with_prefix() {
354        let tags = vec![
355            "agents-v1.0.0".to_string(),
356            "agents-v2.0.0".to_string(),
357            "snippets-v1.5.0".to_string(),
358            "v1.0.0".to_string(),
359            "main".to_string(),
360        ];
361
362        let versions = parse_tags_to_versions(tags);
363
364        // Should parse 4 tags (all except "main")
365        assert_eq!(versions.len(), 4);
366
367        // Verify prefixed tags are parsed correctly
368        assert!(versions.iter().any(|(tag, _)| tag == "agents-v2.0.0"));
369        assert!(versions.iter().any(|(tag, _)| tag == "agents-v1.0.0"));
370        assert!(versions.iter().any(|(tag, _)| tag == "snippets-v1.5.0"));
371        assert!(versions.iter().any(|(tag, _)| tag == "v1.0.0"));
372    }
373
374    #[test]
375    fn test_find_best_matching_tag_with_prefix() {
376        let tags = vec![
377            "agents-v1.0.0".to_string(),
378            "agents-v1.2.0".to_string(),
379            "agents-v2.0.0".to_string(),
380            "snippets-v1.5.0".to_string(),
381            "snippets-v2.0.0".to_string(),
382            "v1.0.0".to_string(),
383        ];
384
385        // Prefixed constraint should match only tags with same prefix
386        let result = find_best_matching_tag("agents-^v1.0.0", tags.clone()).unwrap();
387        assert_eq!(result, "agents-v1.2.0"); // Highest agents 1.x
388
389        // Different prefix
390        let result = find_best_matching_tag("snippets-^v1.0.0", tags.clone()).unwrap();
391        assert_eq!(result, "snippets-v1.5.0"); // Highest snippets 1.x
392
393        // Unprefixed constraint should only match unprefixed tags
394        let result = find_best_matching_tag("^v1.0.0", tags.clone()).unwrap();
395        assert_eq!(result, "v1.0.0"); // Only unprefixed tag matching ^1.0
396    }
397
398    #[test]
399    fn test_prefix_isolation_in_matching() {
400        let tags = vec![
401            "agents-v1.0.0".to_string(),
402            "snippets-v2.0.0".to_string(), // Higher version but different prefix
403        ];
404
405        // Should NOT match snippets even though it has higher version
406        let result = find_best_matching_tag("agents-^v1.0.0", tags.clone()).unwrap();
407        assert_eq!(result, "agents-v1.0.0");
408    }
409
410    #[test]
411    fn test_find_best_matching_tag_no_matching_prefix() {
412        let tags = vec!["agents-v1.0.0".to_string(), "snippets-v1.0.0".to_string()];
413
414        // No tags with "commands" prefix
415        let result = find_best_matching_tag("commands-^v1.0.0", tags);
416        assert!(result.is_err());
417        assert!(result.unwrap_err().to_string().contains("No tags found with matching prefix"));
418    }
419
420    #[test]
421    fn test_parse_prefixed_tags_with_hyphens() {
422        let tags = vec!["my-cool-agent-v1.0.0".to_string(), "tool-v-v2.0.0".to_string()];
423
424        let versions = parse_tags_to_versions(tags);
425
426        assert_eq!(versions.len(), 2);
427        // Both should parse correctly despite hyphens in prefix
428        assert!(versions.iter().any(|(tag, ver)| {
429            tag == "my-cool-agent-v1.0.0" && *ver == Version::parse("1.0.0").unwrap()
430        }));
431        assert!(versions.iter().any(|(tag, ver)| {
432            tag == "tool-v-v2.0.0" && *ver == Version::parse("2.0.0").unwrap()
433        }));
434    }
435}