use std::fmt;
use semver::{Version, VersionReq};
use crate::error::{Result, TuffError};
use crate::git::RemoteTag;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionRequest {
Exact(Version),
Range(VersionReq),
}
impl VersionRequest {
pub fn parse(text: &str) -> Result<Self> {
let text = text.trim();
if text.is_empty() {
return Err(TuffError::usage("a version requirement cannot be empty"));
}
if let Ok(version) = Version::parse(text.strip_prefix('v').unwrap_or(text)) {
return Ok(Self::Exact(version));
}
VersionReq::parse(text).map(Self::Range).map_err(|error| {
TuffError::usage(format!(
"'{text}' is not a version or a version range: {error}"
))
.with_hint("use an exact release such as 1.2.0, or a range such as ^1.2 or >=1, <2")
})
}
pub fn matches(&self, version: &Version) -> bool {
match self {
Self::Exact(exact) => exact == version,
Self::Range(range) => range.matches(version),
}
}
}
impl fmt::Display for VersionRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Exact(version) => write!(f, "{version}"),
Self::Range(range) => write!(f, "{range}"),
}
}
}
pub fn split_version_request(spec: &str) -> Result<(&str, Option<&str>)> {
let Some((name, request)) = spec.split_once('@') else {
return Ok((spec, None));
};
if name.is_empty() {
return Err(TuffError::usage(format!(
"'{spec}' has a version requirement but no capability name"
))
.with_hint("write <name>@<version>, as in security-review@^1.2"));
}
if request.is_empty() {
return Err(
TuffError::usage(format!("'{spec}' ends in '@' with no version after it"))
.with_hint("write <name>@<version>, as in security-review@^1.2, or drop the '@'"),
);
}
Ok((name, Some(request)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReleaseTag {
pub tag: String,
pub version: Version,
pub scoped: bool,
}
pub fn parse_release_tag(tag: &str, name: &str) -> Option<ReleaseTag> {
let scoped_rest = if name.is_empty() {
None
} else {
tag.strip_prefix(name)
.and_then(|rest| rest.strip_prefix('/').or_else(|| rest.strip_prefix('-')))
};
let (rest, scoped) = match scoped_rest {
Some(rest) => (rest, true),
None => (tag, false),
};
let version = Version::parse(rest.strip_prefix('v').unwrap_or(rest)).ok()?;
Some(ReleaseTag {
tag: tag.to_string(),
version,
scoped,
})
}
pub fn release_tags<'a>(tags: impl IntoIterator<Item = &'a str>, name: &str) -> Vec<ReleaseTag> {
let mut releases: Vec<ReleaseTag> = tags
.into_iter()
.filter_map(|tag| parse_release_tag(tag, name))
.collect();
if releases.iter().any(|release| release.scoped) {
releases.retain(|release| release.scoped);
}
releases.sort_by(|a, b| a.version.cmp(&b.version));
releases
}
pub fn latest_release(releases: &[ReleaseTag]) -> Option<&ReleaseTag> {
releases.iter().max_by(|a, b| a.version.cmp(&b.version))
}
pub fn select_release<'a>(
releases: &'a [ReleaseTag],
request: &VersionRequest,
) -> Option<&'a ReleaseTag> {
releases
.iter()
.filter(|release| request.matches(&release.version))
.max_by(|a, b| a.version.cmp(&b.version))
}
pub fn resolve_release<'a>(
tags: impl IntoIterator<Item = &'a str>,
name: &str,
request: &VersionRequest,
) -> Result<ReleaseTag> {
let releases = release_tags(tags, name);
if releases.is_empty() {
return Err(TuffError::not_found(format!(
"no release tags for '{name}' in the repository"
))
.with_hint(format!(
"a tag such as v1.2.0 or {name}/v1.2.0 marks a release; omit the version to install the latest commit"
)));
}
match select_release(&releases, request) {
Some(release) => Ok(release.clone()),
None => {
let available: Vec<String> = releases
.iter()
.rev()
.take(10)
.map(|release| release.version.to_string())
.collect();
Err(TuffError::not_found(format!(
"no release of '{name}' matches {request}; available: {}",
available.join(", ")
)))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TagIntegrity {
Matches,
Repointed { live_commit: String },
Missing,
}
pub fn tag_integrity(
recorded_tag: &str,
recorded_commit: &str,
tags: &[RemoteTag],
) -> TagIntegrity {
match tags.iter().find(|tag| tag.name == recorded_tag) {
None => TagIntegrity::Missing,
Some(tag) if tag.commit == recorded_commit => TagIntegrity::Matches,
Some(tag) => TagIntegrity::Repointed {
live_commit: tag.commit.clone(),
},
}
}
pub fn change_kind(from: &Version, to: &Version) -> &'static str {
if to.major != from.major {
"major"
} else if to.minor != from.minor {
"minor"
} else {
"patch"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tags(list: &[&str]) -> Vec<String> {
list.iter().map(|tag| tag.to_string()).collect()
}
fn remote(list: &[(&str, &str)]) -> Vec<RemoteTag> {
list.iter()
.map(|(name, commit)| RemoteTag {
name: name.to_string(),
commit: commit.to_string(),
})
.collect()
}
#[test]
fn a_tag_is_checked_against_the_commit_that_was_installed() {
let tags = remote(&[("v1.0.0", "aaaa"), ("v1.2.0", "bbbb")]);
assert_eq!(
tag_integrity("v1.2.0", "bbbb", &tags),
TagIntegrity::Matches
);
assert_eq!(
tag_integrity("v1.2.0", "0000", &tags),
TagIntegrity::Repointed {
live_commit: "bbbb".into()
}
);
assert_eq!(
tag_integrity("v1.4.0", "cccc", &tags),
TagIntegrity::Missing
);
}
#[test]
fn a_full_version_is_exact_and_a_partial_one_is_a_range() {
assert_eq!(
VersionRequest::parse("1.2.0").unwrap(),
VersionRequest::Exact(Version::new(1, 2, 0))
);
assert_eq!(
VersionRequest::parse("v1.2.0").unwrap(),
VersionRequest::Exact(Version::new(1, 2, 0))
);
let range = VersionRequest::parse("^1.2").unwrap();
assert!(range.matches(&Version::new(1, 9, 0)));
assert!(!range.matches(&Version::new(2, 0, 0)));
let bare_major = VersionRequest::parse("1").unwrap();
assert!(bare_major.matches(&Version::new(1, 4, 0)));
assert!(!bare_major.matches(&Version::new(2, 0, 0)));
}
#[test]
fn an_unparsable_requirement_is_a_usage_error() {
let error = VersionRequest::parse("latest").unwrap_err();
assert_eq!(error.exit_code(), 2, "{error}");
assert!(VersionRequest::parse("").is_err());
}
#[test]
fn name_and_requirement_split_at_the_at_sign() {
assert_eq!(split_version_request("foo").unwrap(), ("foo", None));
assert_eq!(
split_version_request("foo@^1.2").unwrap(),
("foo", Some("^1.2"))
);
assert!(split_version_request("@1").is_err());
assert!(split_version_request("foo@").is_err());
}
#[test]
fn repo_wide_and_scoped_tag_shapes_are_recognised() {
for (tag, scoped) in [
("v1.4.0", false),
("1.4.0", false),
("foo/v1.4.0", true),
("foo-v1.4.0", true),
("foo/1.4.0", true),
("foo-1.4.0", true),
] {
let release = parse_release_tag(tag, "foo").unwrap_or_else(|| panic!("{tag}"));
assert_eq!(release.version, Version::new(1, 4, 0), "{tag}");
assert_eq!(release.scoped, scoped, "{tag}");
assert_eq!(release.tag, tag);
}
for tag in [
"release-42",
"foobar/v1.0.0",
"bar/v1.0.0",
"foo-bar-v1.0.0",
"v1",
] {
assert!(parse_release_tag(tag, "foo").is_none(), "{tag}");
}
}
#[test]
fn scoped_tags_hide_repo_wide_ones_in_a_monorepo() {
let releases = release_tags(["v9.9.9", "foo/v1.0.0", "bar/v3.0.0"], "foo");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0].tag, "foo/v1.0.0");
let releases = release_tags(["v1.0.0", "v1.2.0", "nightly"], "foo");
assert_eq!(
releases.len(),
2,
"with no scoped tag, repo-wide tags count"
);
}
#[test]
fn selection_takes_the_highest_match_by_version_not_by_string() {
let releases = release_tags(["v1.9.0", "v1.10.0", "v2.0.0", "v1.2.0"], "foo");
let caret = VersionRequest::parse("^1").unwrap();
assert_eq!(select_release(&releases, &caret).unwrap().tag, "v1.10.0");
let exact = VersionRequest::parse("1.2.0").unwrap();
assert_eq!(select_release(&releases, &exact).unwrap().tag, "v1.2.0");
assert_eq!(latest_release(&releases).unwrap().tag, "v2.0.0");
let three = VersionRequest::parse("^3").unwrap();
assert!(select_release(&releases, &three).is_none());
}
#[test]
fn prereleases_are_not_picked_by_a_range() {
let releases = release_tags(["v1.0.0", "v2.0.0-rc.1"], "foo");
let any = VersionRequest::parse(">=1").unwrap();
assert_eq!(select_release(&releases, &any).unwrap().tag, "v1.0.0");
let exact = VersionRequest::parse("2.0.0-rc.1").unwrap();
assert_eq!(
select_release(&releases, &exact).unwrap().tag,
"v2.0.0-rc.1"
);
}
#[test]
fn resolve_explains_no_tags_and_no_match_differently() {
let request = VersionRequest::parse("^2").unwrap();
let none = resolve_release(
tags(&["nightly"]).iter().map(String::as_str),
"foo",
&request,
)
.unwrap_err();
assert!(
none.to_string().contains("no release tags for 'foo'"),
"{none}"
);
assert!(
none.hint().is_some_and(|hint| hint.contains("foo/v1.2.0")),
"{none:?}"
);
let miss = resolve_release(
tags(&["v1.2.0", "v1.4.0"]).iter().map(String::as_str),
"foo",
&request,
)
.unwrap_err();
assert!(
miss.to_string().contains("no release of 'foo' matches ^2"),
"{miss}"
);
assert!(
miss.to_string().contains("available: 1.4.0, 1.2.0"),
"{miss}"
);
let hit = resolve_release(
tags(&["v1.2.0", "v1.4.0"]).iter().map(String::as_str),
"foo",
&VersionRequest::parse("^1").unwrap(),
)
.unwrap();
assert_eq!(hit.tag, "v1.4.0");
}
#[test]
fn change_kind_reads_the_version_delta() {
let v = |text: &str| Version::parse(text).unwrap();
assert_eq!(change_kind(&v("1.2.0"), &v("1.4.0")), "minor");
assert_eq!(change_kind(&v("1.2.0"), &v("2.0.0")), "major");
assert_eq!(change_kind(&v("1.2.0"), &v("1.2.3")), "patch");
}
}