1use serde::Serialize;
15
16use crate::cli::versions::VersionsArgs;
17use crate::error::RkError;
18use crate::output::Output;
19use crate::{embedded, registry};
20
21#[derive(Debug, Serialize)]
23struct PinResult {
24 tool: String,
26 pinned: String,
28 result: &'static str,
32 #[serde(skip_serializing_if = "Option::is_none")]
34 available: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 commit: Option<String>,
38 #[serde(skip_serializing_if = "Option::is_none")]
40 ref_class: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
44 ref_result: Option<&'static str>,
45 #[serde(skip_serializing_if = "Option::is_none")]
47 ref_commit: Option<String>,
48}
49
50#[derive(Debug, Serialize)]
52struct Report {
53 schema: &'static str,
55 pins: Vec<PinResult>,
57}
58
59pub fn run(args: &VersionsArgs) -> Result<(), RkError> {
66 if !args.check {
67 Output::human().result_raw(embedded::VERSIONS);
68 return Ok(());
69 }
70 let out = Output::new(args.json);
71 let mut results = Vec::new();
72 for pin in registry::pins() {
73 let mut result = pin.check.as_deref().map_or_else(
74 || PinResult {
75 tool: pin.name.clone(),
76 pinned: pin.version.clone(),
77 result: if pin.action.is_some() && pin.commit.is_some() {
80 "no-version-source"
81 } else {
82 "source-unreachable"
83 },
84 available: None,
85 commit: None,
86 ref_class: None,
87 ref_result: None,
88 ref_commit: None,
89 },
90 |url| check_one(&pin.name, &pin.version, url),
91 );
92 if let (Some(action), Some(commit)) = (&pin.action, &pin.commit) {
93 let (ref_result, ref_commit) = resolve_ref(action, commit);
94 result.commit = Some(commit.clone());
95 result.ref_class.clone_from(&pin.ref_class);
96 result.ref_result = Some(ref_result);
97 result.ref_commit = ref_commit;
98 }
99 out.result_line(match (&result.result, &result.available) {
100 (&"update-available", Some(available)) => format!(
101 "update-available {} {} pinned, {available} at the source",
102 result.tool, result.pinned
103 ),
104 _ => format!("{} {} {}", result.result, result.tool, result.pinned),
105 });
106 if let Some(ref_result) = result.ref_result {
107 let reference = pin
108 .action
109 .as_deref()
110 .and_then(|action| action.split_once('@'))
111 .map_or_else(String::new, |(_, reference)| reference.to_owned());
112 out.result_line(match (ref_result, &result.ref_commit) {
113 ("ref-moved", Some(now)) => format!(
117 "ref-moved {}: {reference} now names {now}; an update to review, not an incident",
118 result.tool
119 ),
120 ("ref-unmoved", _) => format!(
121 "ref-unmoved {}: {reference} still names the pinned commit",
122 result.tool
123 ),
124 _ => format!("{ref_result} {}: {reference}", result.tool),
125 });
126 }
127 results.push(result);
128 }
129 out.next(&[
130 "a pin update is a reviewed change to versions.toml, with its checked date".to_owned(),
131 ]);
132 out.emit(&Report {
133 schema: "rk.versions-check/2",
134 pins: results,
135 })
136}
137
138fn resolve_ref(action: &str, pinned_commit: &str) -> (&'static str, Option<String>) {
141 let Some((path, reference)) = action.split_once('@') else {
142 return ("ref-unparsable", None);
143 };
144 let mut segments = path.split('/');
148 let (Some(owner), Some(repo)) = (segments.next(), segments.next()) else {
149 return ("ref-unparsable", None);
150 };
151 let encoded: String = reference
156 .bytes()
157 .map(|byte| {
158 if byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'~') {
159 char::from(byte).to_string()
160 } else {
161 format!("%{byte:02X}")
162 }
163 })
164 .collect();
165 let url = format!("https://api.github.com/repos/{owner}/{repo}/commits/{encoded}");
166 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
167 let fetched = std::process::Command::new(curl)
168 .args(["-fsSL", "--max-time", "10", &url])
169 .output();
170 let body = match fetched {
171 Ok(output) if output.status.success() => output.stdout,
172 _ => return ("ref-unreachable", None),
173 };
174 let Some(sha) = serde_json::from_slice::<serde_json::Value>(&body)
175 .ok()
176 .and_then(|value| {
177 value
178 .get("sha")
179 .and_then(serde_json::Value::as_str)
180 .map(str::to_owned)
181 })
182 else {
183 return ("ref-unparsable", None);
184 };
185 if sha == pinned_commit {
186 ("ref-unmoved", Some(sha))
187 } else {
188 ("ref-moved", Some(sha))
189 }
190}
191
192const MAX_PAGES: u32 = 10;
197
198fn fetch(url: &str) -> Option<Vec<u8>> {
200 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
201 let fetched = std::process::Command::new(curl)
202 .args(["-fsSL", "--max-time", "10", url])
203 .output();
204 match fetched {
205 Ok(output) if output.status.success() => Some(output.stdout),
206 _ => None,
207 }
208}
209
210fn paged(url: &str, page: u32) -> String {
213 let joiner = if url.contains('?') { '&' } else { '?' };
214 format!("{url}{joiner}page={page}")
215}
216
217fn check_one(tool: &str, pinned: &str, url: &str) -> PinResult {
231 let result = |result, available| PinResult {
232 tool: tool.to_owned(),
233 pinned: pinned.to_owned(),
234 result,
235 available,
236 commit: None,
237 ref_class: None,
238 ref_result: None,
239 ref_commit: None,
240 };
241 let Some(body) = fetch(url) else {
242 return result("source-unreachable", None);
243 };
244 let mut best = latest_version(&body);
245 if is_page(&body) && !is_empty_page(&body) {
246 let mut ended = false;
247 for page in 2..=MAX_PAGES {
248 let Some(body) = fetch(&paged(url, page)) else {
249 return result("source-unreachable", None);
250 };
251 if !is_page(&body) {
256 return result("source-unparsable", None);
257 }
258 if is_empty_page(&body) {
259 ended = true;
260 break;
261 }
262 best = greater(best, latest_version(&body));
263 }
264 if !ended {
265 return result("source-unreachable", None);
266 }
267 }
268 let Some(available) = best else {
269 return result("source-unparsable", None);
270 };
271 if is_current(pinned, &available) {
272 result("current", Some(available))
273 } else {
274 result("update-available", Some(available))
275 }
276}
277
278fn is_page(body: &[u8]) -> bool {
280 serde_json::from_slice::<serde_json::Value>(body).is_ok_and(|value| value.is_array())
281}
282
283fn is_empty_page(body: &[u8]) -> bool {
285 serde_json::from_slice::<serde_json::Value>(body)
286 .is_ok_and(|value| value.as_array().is_some_and(Vec::is_empty))
287}
288
289fn greater(left: Option<String>, right: Option<String>) -> Option<String> {
292 match (left, right) {
293 (Some(left), Some(right)) => {
294 if numeric_parts(&right) > numeric_parts(&left) {
295 Some(right)
296 } else {
297 Some(left)
298 }
299 }
300 (some, None) | (None, some) => some,
301 }
302}
303
304fn latest_version(body: &[u8]) -> Option<String> {
314 let value: serde_json::Value = serde_json::from_slice(body).ok()?;
315 if let Some(tags) = value.as_array() {
316 return tags
317 .iter()
318 .filter_map(|tag| tag.get("name").and_then(serde_json::Value::as_str))
319 .filter_map(number_from)
320 .max_by(|left, right| numeric_parts(left).cmp(&numeric_parts(right)));
321 }
322 let raw = value
323 .get("crate")
324 .and_then(|krate| krate.get("max_stable_version"))
325 .or_else(|| value.get("tag_name"))
326 .and_then(serde_json::Value::as_str)?;
327 number_from(raw)
328}
329
330fn number_from(raw: &str) -> Option<String> {
333 let start = raw.find(|c: char| c.is_ascii_digit())?;
334 Some(raw[start..].to_owned())
335}
336
337fn numeric_parts(version: &str) -> Vec<u64> {
341 version
342 .split('.')
343 .map_while(|part| part.parse::<u64>().ok())
344 .collect()
345}
346
347fn is_current(pinned: &str, available: &str) -> bool {
350 if pinned == available {
351 return true;
352 }
353 !pinned.contains('.') && available.split('.').next() == Some(pinned)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::{PinResult, Report, is_current, latest_version};
359
360 #[test]
361 fn a_source_version_is_read_from_both_answer_shapes() {
362 assert_eq!(
363 latest_version(br#"{"crate":{"max_stable_version":"0.3.170"}}"#),
364 Some("0.3.170".to_owned())
365 );
366 assert_eq!(
367 latest_version(br#"{"tag_name":"v2.13.1"}"#),
368 Some("2.13.1".to_owned())
369 );
370 assert_eq!(
371 latest_version(br#"{"tag_name":"release-plz-v0.3.160"}"#),
372 Some("0.3.160".to_owned())
373 );
374 assert_eq!(latest_version(b"not json"), None);
375 assert_eq!(latest_version(br#"{"unrelated":true}"#), None);
376 }
377
378 #[test]
382 fn a_tags_answer_reads_the_greatest_name() {
383 assert_eq!(
384 latest_version(br#"[{"name":"2.35.0"},{"name":"2.35.2"},{"name":"2.34.8"}]"#),
385 Some("2.35.2".to_owned())
386 );
387 assert_eq!(
388 latest_version(br#"[{"name":"2.9.0"},{"name":"2.10.0"}]"#),
389 Some("2.10.0".to_owned())
390 );
391 assert_eq!(
392 latest_version(br#"[{"name":"v1.2.3"}]"#),
393 Some("1.2.3".to_owned())
394 );
395 assert_eq!(latest_version(b"[]"), None);
396 assert_eq!(latest_version(br#"[{"sha":"abc"}]"#), None);
397 }
398
399 #[test]
400 fn a_major_only_pin_is_current_within_its_major() {
401 assert!(is_current("0.3.160", "0.3.160"));
402 assert!(!is_current("0.3.160", "0.3.170"));
403 assert!(is_current("4", "4.3.1"));
404 assert!(!is_current("4", "5.0.0"));
405 }
406
407 #[test]
409 fn the_versions_check_schema_snapshot_holds() {
410 let report = Report {
411 schema: "rk.versions-check/2",
412 pins: vec![PinResult {
413 tool: "release-plz".into(),
414 pinned: "0.3.160".into(),
415 result: "update-available",
416 available: Some("0.3.170".into()),
417 commit: Some("2eb1d8bcb770b4c48ccfaad919734b38b51958c9".into()),
418 ref_class: Some("moving-minor-tag".into()),
419 ref_result: Some("ref-unmoved"),
420 ref_commit: Some("2eb1d8bcb770b4c48ccfaad919734b38b51958c9".into()),
421 }],
422 };
423 assert_eq!(
424 serde_json::to_string(&report).expect("a report serializes"),
425 r#"{"schema":"rk.versions-check/2","pins":[{"tool":"release-plz","pinned":"0.3.160","result":"update-available","available":"0.3.170","commit":"2eb1d8bcb770b4c48ccfaad919734b38b51958c9","ref_class":"moving-minor-tag","ref_result":"ref-unmoved","ref_commit":"2eb1d8bcb770b4c48ccfaad919734b38b51958c9"}]}"#
426 );
427 }
428}