Skip to main content

tuff_core/
release.rs

1//! Releases of a git-sourced capability, read from the repository's tags
2//! (RFC-101).
3//!
4//! Nobody assigns versions to skills today, so Tuff derives them from the
5//! one convention that already exists in the wild: a tag such as `v1.4.0`.
6//! A monorepo tags per capability, `<name>/v1.4.0` or `<name>-v1.4.0`, and
7//! when any tag is scoped to the capability only those count, so a repo-wide
8//! `v2.0.0` cannot be mistaken for a release of one of its skills.
9//!
10//! Everything here is pure: the caller lists the tags (`git ls-remote`) and
11//! clones the chosen one. The lockfile keeps pinning the commit; the tag and
12//! the requirement are recorded beside it so `update` can move within the
13//! requirement and `outdated` can say how far behind an install is.
14
15use std::fmt;
16
17use semver::{Version, VersionReq};
18
19use crate::error::{Result, TuffError};
20use crate::git::RemoteTag;
21
22/// What the user asked for after the `@` in `name@1.2.0` or `name@^1.2`.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum VersionRequest {
25    /// Exactly this release.
26    Exact(Version),
27    /// The highest release inside a range.
28    Range(VersionReq),
29}
30
31impl VersionRequest {
32    /// Parse the text after `@`. An exact version is tried first, because
33    /// `1.2.0` also parses as the range `^1.2.0` and the user who typed a
34    /// full version meant that version. A leading `v` is accepted on an
35    /// exact version since that is how the tag is usually spelled.
36    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
69/// Split `name@requirement` into its parts. A bare `name` has no
70/// requirement; an empty name or an empty requirement is a usage error.
71pub 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/// A tag that names a release of one capability.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ReleaseTag {
93    /// The tag as the repository spells it, `v1.4.0` or `foo/v1.4.0`.
94    pub tag: String,
95    pub version: Version,
96    /// Whether the tag names the capability, as in a monorepo.
97    pub scoped: bool,
98}
99
100/// Read one tag as a release of `name`, if it is one. Recognised shapes are
101/// `v1.4.0` and `1.4.0` for the whole repository, and `<name>/v1.4.0` or
102/// `<name>-v1.4.0`, with or without the `v`, for one capability in it.
103pub 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
122/// The releases of `name` among a repository's tags, oldest first. When any
123/// tag is scoped to the capability, only scoped tags count: a monorepo's
124/// repo-wide tag is not a release of one of its members.
125pub 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
137/// The newest release, if there is one.
138pub fn latest_release(releases: &[ReleaseTag]) -> Option<&ReleaseTag> {
139    releases.iter().max_by(|a, b| a.version.cmp(&b.version))
140}
141
142/// The newest release satisfying the request.
143pub 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
153/// Choose the release of `name` that satisfies `request`, from a raw tag
154/// list, with an error that says what was available when nothing does.
155pub 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/// Whether the tag an install was pinned to still names the commit that was
187/// installed. A tag is mutable by design; the lockfile pins the commit, so
188/// the install itself is unaffected, but a tag that now names different
189/// content means the version you have may not be what you think it is.
190/// That is a different finding from "a newer release exists".
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub enum TagIntegrity {
193    /// The tag still names the installed commit.
194    Matches,
195    /// The tag now names a different commit.
196    Repointed { live_commit: String },
197    /// The repository no longer has the tag.
198    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
215/// The claimed size of moving from one release to another, read from the
216/// version numbers alone. It is what the author claimed, not what the diff
217/// shows, which is why `outdated` prints it beside the versions and
218/// `tuff diff --upstream` stays one keystroke away.
219pub 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        // The repository is at 9.9.9; the skill inside it is at 1.0.0. The
330        // repo-wide tag must not read as a release of the skill.
331        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}