Skip to main content

pray_core/
constraint.rs

1use crate::{PrayError, PrayResult};
2use semver::{Version, VersionReq};
3
4/// Normalizes a Prayfile version constraint per RFC 0010 ยง16.
5///
6/// Bare semver strings such as `1.0.0` are exact pins (`=1.0.0`), not caret ranges.
7pub 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
62/// Builds a Ruby pessimistic constraint (`~>`) that allows the given release line.
63pub 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
73/// Derives a Prayfile constraint that admits `latest_version`, preserving operator style.
74pub 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}