Skip to main content

release_kit/commands/
versions.rs

1//! `rk versions`: the pinned-tool registry, and its freshness check.
2//!
3//! Plain `rk versions` prints the registry exactly as authored, offline.
4//! `--check` is the canon-side freshness answer and the one verb allowed
5//! to fetch: it consults each pin's check URL and reports per pin, where
6//! an unreachable or unparsable source is a reported result at exit 0,
7//! not an error — and it never edits `versions.toml`, because a pin
8//! update is a reviewed change in this repository. The fetch goes through
9//! `curl`, resolved like the forge CLIs with `RK_CURL_BIN` as the
10//! override, so the check needs no HTTP stack of its own and a test can
11//! substitute the network.
12
13use serde::Serialize;
14
15use crate::cli::versions::VersionsArgs;
16use crate::error::RkError;
17use crate::output::Output;
18use crate::{embedded, registry};
19
20/// One pin's check result.
21#[derive(Debug, Serialize)]
22struct PinResult {
23    /// The tool's registry name.
24    tool: String,
25    /// The pinned version.
26    pinned: String,
27    /// `current`, `update-available`, `source-unreachable`, or
28    /// `source-unparsable`.
29    result: &'static str,
30    /// The version the source serves, where one was read.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    available: Option<String>,
33}
34
35/// The machine form of a check report.
36#[derive(Debug, Serialize)]
37struct Report {
38    /// The shape version of this document.
39    schema: &'static str,
40    /// One result per pin, in registry order.
41    pins: Vec<PinResult>,
42}
43
44/// Print the registry, or check each pin upstream under `--check`.
45///
46/// # Errors
47///
48/// Returns [`RkError::Other`] only when the report cannot serialize; an
49/// unreachable or unparsable source is a reported result, not a failure.
50pub fn run(args: &VersionsArgs) -> Result<(), RkError> {
51    if !args.check {
52        Output::human().result_raw(embedded::VERSIONS);
53        return Ok(());
54    }
55    let out = Output::new(args.json);
56    let mut results = Vec::new();
57    for pin in registry::pins() {
58        let result = pin.check.as_deref().map_or_else(
59            || PinResult {
60                tool: pin.name.clone(),
61                pinned: pin.version.clone(),
62                result: "source-unreachable",
63                available: None,
64            },
65            |url| check_one(&pin.name, &pin.version, url),
66        );
67        out.result_line(match (&result.result, &result.available) {
68            (&"update-available", Some(available)) => format!(
69                "update-available {} {} pinned, {available} at the source",
70                result.tool, result.pinned
71            ),
72            _ => format!("{} {} {}", result.result, result.tool, result.pinned),
73        });
74        results.push(result);
75    }
76    out.next(&[
77        "a pin update is a reviewed change to versions.toml, with its checked date".to_owned(),
78    ]);
79    out.emit(&Report {
80        schema: "rk.versions-check/1",
81        pins: results,
82    })
83}
84
85/// Fetch one check URL and classify the answer.
86fn check_one(tool: &str, pinned: &str, url: &str) -> PinResult {
87    let result = |result, available| PinResult {
88        tool: tool.to_owned(),
89        pinned: pinned.to_owned(),
90        result,
91        available,
92    };
93    let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
94    let fetched = std::process::Command::new(curl)
95        .args(["-fsSL", "--max-time", "10", url])
96        .output();
97    let body = match fetched {
98        Ok(output) if output.status.success() => output.stdout,
99        _ => return result("source-unreachable", None),
100    };
101    let Some(available) = latest_version(&body) else {
102        return result("source-unparsable", None);
103    };
104    if is_current(pinned, &available) {
105        result("current", Some(available))
106    } else {
107        result("update-available", Some(available))
108    }
109}
110
111/// The latest version a source's JSON names: `max_stable_version` from a
112/// crates.io answer, `tag_name` from a forge's releases answer.
113fn latest_version(body: &[u8]) -> Option<String> {
114    let value: serde_json::Value = serde_json::from_slice(body).ok()?;
115    let raw = value
116        .get("crate")
117        .and_then(|krate| krate.get("max_stable_version"))
118        .or_else(|| value.get("tag_name"))
119        .and_then(serde_json::Value::as_str)?;
120    // A tag may prefix the number — `v2.13.1`, or a name before it — so
121    // the version starts at the first digit.
122    let start = raw.find(|c: char| c.is_ascii_digit())?;
123    Some(raw[start..].to_owned())
124}
125
126/// Whether the pin already matches the source: exactly, or — for a pin
127/// naming only a major, as the action pins do — by major version.
128fn is_current(pinned: &str, available: &str) -> bool {
129    if pinned == available {
130        return true;
131    }
132    !pinned.contains('.') && available.split('.').next() == Some(pinned)
133}
134
135#[cfg(test)]
136mod tests {
137    #![allow(clippy::expect_used)]
138
139    use super::{PinResult, Report, is_current, latest_version};
140
141    #[test]
142    fn a_source_version_is_read_from_both_answer_shapes() {
143        assert_eq!(
144            latest_version(br#"{"crate":{"max_stable_version":"0.3.170"}}"#),
145            Some("0.3.170".to_owned())
146        );
147        assert_eq!(
148            latest_version(br#"{"tag_name":"v2.13.1"}"#),
149            Some("2.13.1".to_owned())
150        );
151        assert_eq!(
152            latest_version(br#"{"tag_name":"release-plz-v0.3.160"}"#),
153            Some("0.3.160".to_owned())
154        );
155        assert_eq!(latest_version(b"not json"), None);
156        assert_eq!(latest_version(br#"{"unrelated":true}"#), None);
157    }
158
159    #[test]
160    fn a_major_only_pin_is_current_within_its_major() {
161        assert!(is_current("0.3.160", "0.3.160"));
162        assert!(!is_current("0.3.160", "0.3.170"));
163        assert!(is_current("4", "4.3.1"));
164        assert!(!is_current("4", "5.0.0"));
165    }
166
167    /// The complete `rk.versions-check/1` shape, held by snapshot.
168    #[test]
169    fn the_versions_check_schema_snapshot_holds() {
170        let report = Report {
171            schema: "rk.versions-check/1",
172            pins: vec![PinResult {
173                tool: "release-plz".into(),
174                pinned: "0.3.160".into(),
175                result: "update-available",
176                available: Some("0.3.170".into()),
177            }],
178        };
179        assert_eq!(
180            serde_json::to_string(&report).expect("a report serializes"),
181            r#"{"schema":"rk.versions-check/1","pins":[{"tool":"release-plz","pinned":"0.3.160","result":"update-available","available":"0.3.170"}]}"#
182        );
183    }
184}