1use std::fmt;
16
17use semver::{Version, VersionReq};
18
19use crate::error::{Result, TuffError};
20use crate::git::RemoteTag;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum VersionRequest {
25 Exact(Version),
27 Range(VersionReq),
29}
30
31impl VersionRequest {
32 pub fn parse(text: &str) -> Result<Self> {
37 let text = text.trim();
38 if text.is_empty() {
39 return Err(TuffError::usage("a version requirement cannot be empty"));
40 }
41 if let Ok(version) = Version::parse(text.strip_prefix('v').unwrap_or(text)) {
42 return Ok(Self::Exact(version));
43 }
44 VersionReq::parse(text).map(Self::Range).map_err(|error| {
45 TuffError::usage(format!(
46 "'{text}' is not a version or a version range: {error}"
47 ))
48 .with_hint("use an exact release such as 1.2.0, or a range such as ^1.2 or >=1, <2")
49 })
50 }
51
52 pub fn matches(&self, version: &Version) -> bool {
53 match self {
54 Self::Exact(exact) => exact == version,
55 Self::Range(range) => range.matches(version),
56 }
57 }
58}
59
60impl fmt::Display for VersionRequest {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::Exact(version) => write!(f, "{version}"),
64 Self::Range(range) => write!(f, "{range}"),
65 }
66 }
67}
68
69pub fn split_version_request(spec: &str) -> Result<(&str, Option<&str>)> {
72 let Some((name, request)) = spec.split_once('@') else {
73 return Ok((spec, None));
74 };
75 if name.is_empty() {
76 return Err(TuffError::usage(format!(
77 "'{spec}' has a version requirement but no capability name"
78 ))
79 .with_hint("write <name>@<version>, as in security-review@^1.2"));
80 }
81 if request.is_empty() {
82 return Err(
83 TuffError::usage(format!("'{spec}' ends in '@' with no version after it"))
84 .with_hint("write <name>@<version>, as in security-review@^1.2, or drop the '@'"),
85 );
86 }
87 Ok((name, Some(request)))
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ReleaseTag {
93 pub tag: String,
95 pub version: Version,
96 pub scoped: bool,
98}
99
100pub fn parse_release_tag(tag: &str, name: &str) -> Option<ReleaseTag> {
104 let scoped_rest = if name.is_empty() {
105 None
106 } else {
107 tag.strip_prefix(name)
108 .and_then(|rest| rest.strip_prefix('/').or_else(|| rest.strip_prefix('-')))
109 };
110 let (rest, scoped) = match scoped_rest {
111 Some(rest) => (rest, true),
112 None => (tag, false),
113 };
114 let version = Version::parse(rest.strip_prefix('v').unwrap_or(rest)).ok()?;
115 Some(ReleaseTag {
116 tag: tag.to_string(),
117 version,
118 scoped,
119 })
120}
121
122pub fn release_tags<'a>(tags: impl IntoIterator<Item = &'a str>, name: &str) -> Vec<ReleaseTag> {
126 let mut releases: Vec<ReleaseTag> = tags
127 .into_iter()
128 .filter_map(|tag| parse_release_tag(tag, name))
129 .collect();
130 if releases.iter().any(|release| release.scoped) {
131 releases.retain(|release| release.scoped);
132 }
133 releases.sort_by(|a, b| a.version.cmp(&b.version));
134 releases
135}
136
137pub fn latest_release(releases: &[ReleaseTag]) -> Option<&ReleaseTag> {
139 releases.iter().max_by(|a, b| a.version.cmp(&b.version))
140}
141
142pub fn select_release<'a>(
144 releases: &'a [ReleaseTag],
145 request: &VersionRequest,
146) -> Option<&'a ReleaseTag> {
147 releases
148 .iter()
149 .filter(|release| request.matches(&release.version))
150 .max_by(|a, b| a.version.cmp(&b.version))
151}
152
153pub fn resolve_release<'a>(
156 tags: impl IntoIterator<Item = &'a str>,
157 name: &str,
158 request: &VersionRequest,
159) -> Result<ReleaseTag> {
160 let releases = release_tags(tags, name);
161 if releases.is_empty() {
162 return Err(TuffError::not_found(format!(
163 "no release tags for '{name}' in the repository"
164 ))
165 .with_hint(format!(
166 "a tag such as v1.2.0 or {name}/v1.2.0 marks a release; omit the version to install the latest commit"
167 )));
168 }
169 match select_release(&releases, request) {
170 Some(release) => Ok(release.clone()),
171 None => {
172 let available: Vec<String> = releases
173 .iter()
174 .rev()
175 .take(10)
176 .map(|release| release.version.to_string())
177 .collect();
178 Err(TuffError::not_found(format!(
179 "no release of '{name}' matches {request}; available: {}",
180 available.join(", ")
181 )))
182 }
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum TagIntegrity {
193 Matches,
195 Repointed { live_commit: String },
197 Missing,
199}
200
201pub fn tag_integrity(
202 recorded_tag: &str,
203 recorded_commit: &str,
204 tags: &[RemoteTag],
205) -> TagIntegrity {
206 match tags.iter().find(|tag| tag.name == recorded_tag) {
207 None => TagIntegrity::Missing,
208 Some(tag) if tag.commit == recorded_commit => TagIntegrity::Matches,
209 Some(tag) => TagIntegrity::Repointed {
210 live_commit: tag.commit.clone(),
211 },
212 }
213}
214
215pub fn change_kind(from: &Version, to: &Version) -> &'static str {
220 if to.major != from.major {
221 "major"
222 } else if to.minor != from.minor {
223 "minor"
224 } else {
225 "patch"
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn tags(list: &[&str]) -> Vec<String> {
234 list.iter().map(|tag| tag.to_string()).collect()
235 }
236
237 fn remote(list: &[(&str, &str)]) -> Vec<RemoteTag> {
238 list.iter()
239 .map(|(name, commit)| RemoteTag {
240 name: name.to_string(),
241 commit: commit.to_string(),
242 })
243 .collect()
244 }
245
246 #[test]
247 fn a_tag_is_checked_against_the_commit_that_was_installed() {
248 let tags = remote(&[("v1.0.0", "aaaa"), ("v1.2.0", "bbbb")]);
249 assert_eq!(
250 tag_integrity("v1.2.0", "bbbb", &tags),
251 TagIntegrity::Matches
252 );
253 assert_eq!(
254 tag_integrity("v1.2.0", "0000", &tags),
255 TagIntegrity::Repointed {
256 live_commit: "bbbb".into()
257 }
258 );
259 assert_eq!(
260 tag_integrity("v1.4.0", "cccc", &tags),
261 TagIntegrity::Missing
262 );
263 }
264
265 #[test]
266 fn a_full_version_is_exact_and_a_partial_one_is_a_range() {
267 assert_eq!(
268 VersionRequest::parse("1.2.0").unwrap(),
269 VersionRequest::Exact(Version::new(1, 2, 0))
270 );
271 assert_eq!(
272 VersionRequest::parse("v1.2.0").unwrap(),
273 VersionRequest::Exact(Version::new(1, 2, 0))
274 );
275 let range = VersionRequest::parse("^1.2").unwrap();
276 assert!(range.matches(&Version::new(1, 9, 0)));
277 assert!(!range.matches(&Version::new(2, 0, 0)));
278 let bare_major = VersionRequest::parse("1").unwrap();
279 assert!(bare_major.matches(&Version::new(1, 4, 0)));
280 assert!(!bare_major.matches(&Version::new(2, 0, 0)));
281 }
282
283 #[test]
284 fn an_unparsable_requirement_is_a_usage_error() {
285 let error = VersionRequest::parse("latest").unwrap_err();
286 assert_eq!(error.exit_code(), 2, "{error}");
287 assert!(VersionRequest::parse("").is_err());
288 }
289
290 #[test]
291 fn name_and_requirement_split_at_the_at_sign() {
292 assert_eq!(split_version_request("foo").unwrap(), ("foo", None));
293 assert_eq!(
294 split_version_request("foo@^1.2").unwrap(),
295 ("foo", Some("^1.2"))
296 );
297 assert!(split_version_request("@1").is_err());
298 assert!(split_version_request("foo@").is_err());
299 }
300
301 #[test]
302 fn repo_wide_and_scoped_tag_shapes_are_recognised() {
303 for (tag, scoped) in [
304 ("v1.4.0", false),
305 ("1.4.0", false),
306 ("foo/v1.4.0", true),
307 ("foo-v1.4.0", true),
308 ("foo/1.4.0", true),
309 ("foo-1.4.0", true),
310 ] {
311 let release = parse_release_tag(tag, "foo").unwrap_or_else(|| panic!("{tag}"));
312 assert_eq!(release.version, Version::new(1, 4, 0), "{tag}");
313 assert_eq!(release.scoped, scoped, "{tag}");
314 assert_eq!(release.tag, tag);
315 }
316 for tag in [
317 "release-42",
318 "foobar/v1.0.0",
319 "bar/v1.0.0",
320 "foo-bar-v1.0.0",
321 "v1",
322 ] {
323 assert!(parse_release_tag(tag, "foo").is_none(), "{tag}");
324 }
325 }
326
327 #[test]
328 fn scoped_tags_hide_repo_wide_ones_in_a_monorepo() {
329 let releases = release_tags(["v9.9.9", "foo/v1.0.0", "bar/v3.0.0"], "foo");
332 assert_eq!(releases.len(), 1);
333 assert_eq!(releases[0].tag, "foo/v1.0.0");
334
335 let releases = release_tags(["v1.0.0", "v1.2.0", "nightly"], "foo");
336 assert_eq!(
337 releases.len(),
338 2,
339 "with no scoped tag, repo-wide tags count"
340 );
341 }
342
343 #[test]
344 fn selection_takes_the_highest_match_by_version_not_by_string() {
345 let releases = release_tags(["v1.9.0", "v1.10.0", "v2.0.0", "v1.2.0"], "foo");
346 let caret = VersionRequest::parse("^1").unwrap();
347 assert_eq!(select_release(&releases, &caret).unwrap().tag, "v1.10.0");
348 let exact = VersionRequest::parse("1.2.0").unwrap();
349 assert_eq!(select_release(&releases, &exact).unwrap().tag, "v1.2.0");
350 assert_eq!(latest_release(&releases).unwrap().tag, "v2.0.0");
351 let three = VersionRequest::parse("^3").unwrap();
352 assert!(select_release(&releases, &three).is_none());
353 }
354
355 #[test]
356 fn prereleases_are_not_picked_by_a_range() {
357 let releases = release_tags(["v1.0.0", "v2.0.0-rc.1"], "foo");
358 let any = VersionRequest::parse(">=1").unwrap();
359 assert_eq!(select_release(&releases, &any).unwrap().tag, "v1.0.0");
360 let exact = VersionRequest::parse("2.0.0-rc.1").unwrap();
361 assert_eq!(
362 select_release(&releases, &exact).unwrap().tag,
363 "v2.0.0-rc.1"
364 );
365 }
366
367 #[test]
368 fn resolve_explains_no_tags_and_no_match_differently() {
369 let request = VersionRequest::parse("^2").unwrap();
370 let none = resolve_release(
371 tags(&["nightly"]).iter().map(String::as_str),
372 "foo",
373 &request,
374 )
375 .unwrap_err();
376 assert!(
377 none.to_string().contains("no release tags for 'foo'"),
378 "{none}"
379 );
380 assert!(
381 none.hint().is_some_and(|hint| hint.contains("foo/v1.2.0")),
382 "{none:?}"
383 );
384
385 let miss = resolve_release(
386 tags(&["v1.2.0", "v1.4.0"]).iter().map(String::as_str),
387 "foo",
388 &request,
389 )
390 .unwrap_err();
391 assert!(
392 miss.to_string().contains("no release of 'foo' matches ^2"),
393 "{miss}"
394 );
395 assert!(
396 miss.to_string().contains("available: 1.4.0, 1.2.0"),
397 "{miss}"
398 );
399
400 let hit = resolve_release(
401 tags(&["v1.2.0", "v1.4.0"]).iter().map(String::as_str),
402 "foo",
403 &VersionRequest::parse("^1").unwrap(),
404 )
405 .unwrap();
406 assert_eq!(hit.tag, "v1.4.0");
407 }
408
409 #[test]
410 fn change_kind_reads_the_version_delta() {
411 let v = |text: &str| Version::parse(text).unwrap();
412 assert_eq!(change_kind(&v("1.2.0"), &v("1.4.0")), "minor");
413 assert_eq!(change_kind(&v("1.2.0"), &v("2.0.0")), "major");
414 assert_eq!(change_kind(&v("1.2.0"), &v("1.2.3")), "patch");
415 }
416}