1use crate::{PrayError, PrayResult};
2use semver::{Version, VersionReq};
3
4pub fn normalize_version_constraint(constraint: &str) -> String {
8 let trimmed = constraint.trim();
9 if trimmed.is_empty() || trimmed == "*" {
10 return trimmed.to_string();
11 }
12 if trimmed.starts_with("~>")
13 || trimmed.starts_with('~')
14 || trimmed.starts_with('^')
15 || trimmed.starts_with('=')
16 || trimmed.starts_with('>')
17 || trimmed.starts_with('<')
18 || trimmed.contains('*')
19 {
20 return trimmed.to_string();
21 }
22 if Version::parse(trimmed).is_ok() {
23 return format!("={trimmed}");
24 }
25 trimmed.to_string()
26}
27
28pub fn version_satisfies(version: &str, constraint: &str) -> PrayResult<bool> {
29 let parts: Vec<&str> = constraint
30 .split(',')
31 .map(str::trim)
32 .filter(|part| !part.is_empty())
33 .collect();
34 if parts.is_empty() {
35 return Ok(true);
36 }
37 for part in parts {
38 if !version_satisfies_one(version, part)? {
39 return Ok(false);
40 }
41 }
42 Ok(true)
43}
44
45fn version_satisfies_one(version: &str, constraint: &str) -> PrayResult<bool> {
46 let normalized = normalize_version_constraint(constraint);
47 if normalized.is_empty() || normalized == "*" {
48 return Ok(true);
49 }
50 let version =
51 Version::parse(version).map_err(|error| PrayError::Resolution(error.to_string()))?;
52 let req = if normalized.trim_start().starts_with("~>") {
53 VersionReq::parse(&ruby_pessimistic_to_semver(&normalized)?)
54 .map_err(|error| PrayError::Resolution(error.to_string()))?
55 } else {
56 VersionReq::parse(normalized.trim())
57 .map_err(|error| PrayError::Resolution(error.to_string()))?
58 };
59 Ok(req.matches(&version))
60}
61
62pub fn pessimistic_constraint_for_version(version: &str) -> PrayResult<String> {
64 let parsed =
65 Version::parse(version).map_err(|error| PrayError::Resolution(error.to_string()))?;
66 if parsed.minor == 0 && parsed.patch == 0 {
67 Ok(format!("~> {}.0", parsed.major))
68 } else {
69 Ok(format!("~> {}.{}", parsed.major, parsed.minor))
70 }
71}
72
73pub fn latest_constraint_for_package(
75 current_constraint: &str,
76 latest_version: &str,
77) -> PrayResult<String> {
78 let normalized = normalize_version_constraint(current_constraint);
79 if normalized == "*" {
80 return Ok("*".to_string());
81 }
82 if normalized.starts_with("~") {
83 return pessimistic_constraint_for_version(latest_version);
84 }
85 if normalized.starts_with('^') {
86 let parsed = Version::parse(latest_version)
87 .map_err(|error| PrayError::Resolution(error.to_string()))?;
88 return Ok(format!("^{}.{}", parsed.major, parsed.minor));
89 }
90 if normalized.starts_with('=') || Version::parse(current_constraint.trim()).is_ok() {
91 return Ok(format!("={latest_version}"));
92 }
93 pessimistic_constraint_for_version(latest_version)
94}
95
96fn ruby_pessimistic_to_semver(constraint: &str) -> PrayResult<String> {
97 let text = constraint.trim().trim_start_matches("~>").trim();
98 let parts: Vec<&str> = text.split('.').collect();
99 if parts.is_empty() || parts.len() > 3 {
100 return Err(PrayError::Resolution(format!(
101 "unsupported Ruby pessimistic constraint: {constraint}"
102 )));
103 }
104 let mut numbers = [0u64; 3];
105 for (index, part) in parts.iter().enumerate() {
106 numbers[index] = part
107 .parse::<u64>()
108 .map_err(|error| PrayError::Resolution(error.to_string()))?;
109 }
110 let lower = format!("{}.{}.{}", numbers[0], numbers[1], numbers[2]);
111 let upper = match parts.len() {
112 1 => format!("{}.0.0", numbers[0] + 1),
113 2 => format!("{}.{}.0", numbers[0], numbers[1] + 1),
114 _ => format!("{}.{}.0", numbers[0], numbers[1] + 1),
115 };
116 Ok(format!(">={}, <{}", lower, upper))
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn bare_semver_is_exact_pin() {
125 assert_eq!(normalize_version_constraint("1.0.0"), "=1.0.0");
126 assert_eq!(normalize_version_constraint(" 2.3.4 "), "=2.3.4");
127 }
128
129 #[test]
130 fn explicit_operators_are_preserved() {
131 assert_eq!(normalize_version_constraint("~> 1.0"), "~> 1.0");
132 assert_eq!(normalize_version_constraint("^2.0"), "^2.0");
133 assert_eq!(normalize_version_constraint("= 1.2.3"), "= 1.2.3");
134 assert_eq!(normalize_version_constraint("*"), "*");
135 }
136
137 #[test]
138 fn bare_semver_matches_only_exact_version() {
139 assert!(version_satisfies("1.0.0", "1.0.0").expect("matches"));
140 assert!(!version_satisfies("1.0.1", "1.0.0").expect("does not match"));
141 assert!(version_satisfies("1.0.1", "~> 1.0").expect("pessimistic matches"));
142 }
143
144 #[test]
145 fn pessimistic_constraint_uses_major_minor_line() {
146 assert_eq!(
147 pessimistic_constraint_for_version("2.0.1").expect("constraint"),
148 "~> 2.0"
149 );
150 assert_eq!(
151 pessimistic_constraint_for_version("1.4.3").expect("constraint"),
152 "~> 1.4"
153 );
154 assert!(version_satisfies("2.0.0", "~> 2.0").expect("matches"));
155 }
156
157 #[test]
158 fn latest_constraint_preserves_operator_family() {
159 assert_eq!(
160 latest_constraint_for_package("~> 1.0", "2.0.0").expect("constraint"),
161 "~> 2.0"
162 );
163 assert_eq!(
164 latest_constraint_for_package("1.0.0", "2.0.0").expect("constraint"),
165 "=2.0.0"
166 );
167 assert_eq!(
168 latest_constraint_for_package("^1.0", "2.1.0").expect("constraint"),
169 "^2.1"
170 );
171 }
172}