Skip to main content

release_kit/devshell/
discover.rs

1//! The one network call the devshell verb makes: which release is the
2//! latest.
3//!
4//! The answer is the redirect of the forge's `releases/latest` page. It
5//! costs no API quota and no token, and it excludes prereleases, which
6//! the tag list does not. The fetch goes through `curl`, resolved like
7//! every other soft tool with `RK_CURL_BIN` as the override, so a test
8//! substitutes the network and the binary needs no HTTP stack.
9
10use std::cmp::Ordering;
11
12use super::normalize_tag;
13use super::pin::PIN_PREFIX;
14
15/// What discovery found.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Discovery {
18    /// The latest release's tag, normalized.
19    Tag(String),
20    /// The source did not answer; the detail is curl's last line.
21    Unreachable(String),
22    /// The source answered something that names no tag.
23    Unparsable(String),
24}
25
26/// The page whose redirect names the latest release, derived from the
27/// pin grammar so the project path has one owner.
28#[must_use]
29pub fn latest_url() -> String {
30    let path = PIN_PREFIX
31        .trim_start_matches("github:")
32        .trim_end_matches('/');
33    format!("https://github.com/{path}/releases/latest")
34}
35
36/// The latest release's tag, through one curl call that follows the
37/// redirect and prints the effective URL alone.
38#[must_use]
39pub fn latest_tag() -> Discovery {
40    let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
41    let fetched = std::process::Command::new(curl)
42        .args([
43            "-fsSL",
44            "--max-time",
45            "10",
46            "-o",
47            "/dev/null",
48            "-w",
49            "%{url_effective}",
50            &latest_url(),
51        ])
52        .output();
53    let output = match fetched {
54        Ok(output) if output.status.success() => output,
55        Ok(output) => {
56            return Discovery::Unreachable(crate::maintenance::last_line(&output.stderr));
57        }
58        Err(source) => return Discovery::Unreachable(format!("curl did not run: {source}")),
59    };
60    let url = String::from_utf8_lossy(&output.stdout).trim().to_owned();
61    match url.rsplit_once("/releases/tag/") {
62        Some((_, tail)) => {
63            normalize_tag(tail).map_or_else(|| Discovery::Unparsable(url.clone()), Discovery::Tag)
64        }
65        None => Discovery::Unparsable(url),
66    }
67}
68
69/// Semantic version order over two tags, with or without the leading `v`.
70///
71/// Numeric components compare as numbers, a release outranks its own
72/// prerelease, and prerelease identifiers compare the way semver says.
73/// Anything the grammar cannot read falls back to text order.
74#[must_use]
75pub fn version_order(left: &str, right: &str) -> Ordering {
76    let (left_core, left_pre) = split_version(left);
77    let (right_core, right_pre) = split_version(right);
78    let core = left_core
79        .iter()
80        .zip(right_core.iter())
81        .map(|(a, b)| a.cmp(b))
82        .find(|order| order.is_ne())
83        .unwrap_or_else(|| left_core.len().cmp(&right_core.len()));
84    if core.is_ne() {
85        return core;
86    }
87    match (left_pre, right_pre) {
88        (None, None) => Ordering::Equal,
89        (None, Some(_)) => Ordering::Greater,
90        (Some(_), None) => Ordering::Less,
91        (Some(a), Some(b)) => prerelease_order(a, b),
92    }
93}
94
95/// One component: a number, or an identifier that sorts after every number.
96#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
97enum Component {
98    Number(u64),
99    Text(String),
100}
101
102/// The core components and the prerelease suffix of a tag.
103fn split_version(tag: &str) -> (Vec<Component>, Option<&str>) {
104    let trimmed = tag.trim();
105    let bare = trimmed.strip_prefix('v').unwrap_or(trimmed);
106    let bare = bare.split('+').next().unwrap_or(bare);
107    let (core, pre) = bare
108        .split_once('-')
109        .map_or((bare, None), |(core, pre)| (core, Some(pre)));
110    (core.split('.').map(component).collect(), pre)
111}
112
113fn component(text: &str) -> Component {
114    text.parse()
115        .map_or_else(|_| Component::Text(text.to_owned()), Component::Number)
116}
117
118/// Prerelease order: identifier by identifier, numbers before text,
119/// and the shorter list first where every shared identifier ties.
120fn prerelease_order(left: &str, right: &str) -> Ordering {
121    let a: Vec<Component> = left.split('.').map(component).collect();
122    let b: Vec<Component> = right.split('.').map(component).collect();
123    a.iter()
124        .zip(b.iter())
125        .map(|(x, y)| x.cmp(y))
126        .find(|order| order.is_ne())
127        .unwrap_or_else(|| a.len().cmp(&b.len()))
128}
129
130#[cfg(test)]
131mod tests {
132    use std::cmp::Ordering;
133
134    use super::{latest_url, version_order};
135
136    #[test]
137    fn the_version_order_is_semantic_not_textual() {
138        assert_eq!(version_order("v0.2.16", "v0.2.16"), Ordering::Equal);
139        assert_eq!(version_order("0.2.16", "v0.2.16"), Ordering::Equal);
140        assert_eq!(version_order("v0.2.9", "v0.2.16"), Ordering::Less);
141        assert_eq!(version_order("v0.10.0", "v0.9.9"), Ordering::Greater);
142        assert_eq!(version_order("v1.0.0", "v1.0.0-rc.1"), Ordering::Greater);
143        assert_eq!(version_order("v1.0.0-rc.1", "v1.0.0-rc.2"), Ordering::Less);
144        assert_eq!(
145            version_order("v1.0.0-alpha", "v1.0.0-alpha.1"),
146            Ordering::Less
147        );
148        assert_eq!(version_order("v1.0.0-1", "v1.0.0-beta"), Ordering::Less);
149        assert_eq!(version_order("v1.0.0+build", "v1.0.0"), Ordering::Equal);
150        assert_eq!(version_order("v1.0", "v1.0.0"), Ordering::Less);
151    }
152
153    #[test]
154    fn the_latest_url_derives_from_the_pin_grammar() {
155        assert_eq!(
156            latest_url(),
157            "https://github.com/gubasso/release-kit/releases/latest"
158        );
159    }
160}