agpm_cli/resolver/
version_resolution.rs1use anyhow::Result;
7use semver::Version;
8
9use crate::version::constraints::{ConstraintSet, VersionConstraint};
10
11#[must_use]
42pub fn is_version_constraint(version: &str) -> bool {
43 let (_prefix, version_str) = crate::version::split_prefix_and_version(version);
45
46 if version_str == "*" {
48 return true;
49 }
50
51 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 {
60 return true;
61 }
62
63 false
64}
65
66#[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 let (_prefix, version_str) = crate::version::split_prefix_and_version(&tag);
103
104 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 versions.sort_by(|a, b| b.1.cmp(&a.1));
114
115 versions
116}
117
118pub fn find_best_matching_tag(constraint_str: &str, tags: Vec<String>) -> Result<String> {
155 let (constraint_prefix, version_str) = crate::version::split_prefix_and_version(constraint_str);
157
158 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 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 if version_str == "*" {
184 return Ok(tag_versions[0].0.clone());
186 }
187
188 let constraint = VersionConstraint::parse(version_str)?;
191
192 let versions: Vec<Version> = tag_versions.iter().map(|(_, v)| v.clone()).collect();
194
195 let mut constraint_set = ConstraintSet::new();
197 constraint_set.add(constraint)?;
198
199 if let Some(best_version) = constraint_set.find_best_match(&versions) {
201 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 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 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 let result = find_best_matching_tag("^1.0.0", tags.clone()).unwrap();
269 assert_eq!(result, "v1.5.0");
270
271 let result = find_best_matching_tag("~1.2.0", tags.clone()).unwrap();
273 assert_eq!(result, "v1.2.0");
274
275 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(), "v5.0.0".to_string(), ];
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 #[test]
322 fn test_is_version_constraint_with_prefix() {
323 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 assert!(is_version_constraint("agents-*"));
330 assert!(is_version_constraint("snippets-*"));
331 assert!(is_version_constraint("my-tool-*"));
332
333 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 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 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 assert_eq!(versions.len(), 4);
366
367 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 let result = find_best_matching_tag("agents-^v1.0.0", tags.clone()).unwrap();
387 assert_eq!(result, "agents-v1.2.0"); let result = find_best_matching_tag("snippets-^v1.0.0", tags.clone()).unwrap();
391 assert_eq!(result, "snippets-v1.5.0"); let result = find_best_matching_tag("^v1.0.0", tags.clone()).unwrap();
395 assert_eq!(result, "v1.0.0"); }
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(), ];
404
405 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 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 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}