release_kit/devshell/
discover.rs1use std::cmp::Ordering;
11
12use super::normalize_tag;
13use super::pin::PIN_PREFIX;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Discovery {
18 Tag(String),
20 Unreachable(String),
22 Unparsable(String),
24}
25
26#[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#[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#[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#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
97enum Component {
98 Number(u64),
99 Text(String),
100}
101
102fn 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
118fn 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}