1use serde::Serialize;
14
15use crate::cli::versions::VersionsArgs;
16use crate::error::RkError;
17use crate::output::Output;
18use crate::{embedded, registry};
19
20#[derive(Debug, Serialize)]
22struct PinResult {
23 tool: String,
25 pinned: String,
27 result: &'static str,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 available: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 commit: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 ref_class: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
43 ref_result: Option<&'static str>,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 ref_commit: Option<String>,
47}
48
49#[derive(Debug, Serialize)]
51struct Report {
52 schema: &'static str,
54 pins: Vec<PinResult>,
56}
57
58pub fn run(args: &VersionsArgs) -> Result<(), RkError> {
65 if !args.check {
66 Output::human().result_raw(embedded::VERSIONS);
67 return Ok(());
68 }
69 let out = Output::new(args.json);
70 let mut results = Vec::new();
71 for pin in registry::pins() {
72 let mut result = pin.check.as_deref().map_or_else(
73 || PinResult {
74 tool: pin.name.clone(),
75 pinned: pin.version.clone(),
76 result: if pin.action.is_some() && pin.commit.is_some() {
79 "no-version-source"
80 } else {
81 "source-unreachable"
82 },
83 available: None,
84 commit: None,
85 ref_class: None,
86 ref_result: None,
87 ref_commit: None,
88 },
89 |url| check_one(&pin.name, &pin.version, url),
90 );
91 if let (Some(action), Some(commit)) = (&pin.action, &pin.commit) {
92 let (ref_result, ref_commit) = resolve_ref(action, commit);
93 result.commit = Some(commit.clone());
94 result.ref_class.clone_from(&pin.ref_class);
95 result.ref_result = Some(ref_result);
96 result.ref_commit = ref_commit;
97 }
98 out.result_line(match (&result.result, &result.available) {
99 (&"update-available", Some(available)) => format!(
100 "update-available {} {} pinned, {available} at the source",
101 result.tool, result.pinned
102 ),
103 _ => format!("{} {} {}", result.result, result.tool, result.pinned),
104 });
105 if let Some(ref_result) = result.ref_result {
106 let reference = pin
107 .action
108 .as_deref()
109 .and_then(|action| action.split_once('@'))
110 .map_or_else(String::new, |(_, reference)| reference.to_owned());
111 out.result_line(match (ref_result, &result.ref_commit) {
112 ("ref-moved", Some(now)) => format!(
116 "ref-moved {}: {reference} now names {now}; an update to review, not an incident",
117 result.tool
118 ),
119 ("ref-unmoved", _) => format!(
120 "ref-unmoved {}: {reference} still names the pinned commit",
121 result.tool
122 ),
123 _ => format!("{ref_result} {}: {reference}", result.tool),
124 });
125 }
126 results.push(result);
127 }
128 out.next(&[
129 "a pin update is a reviewed change to versions.toml, with its checked date".to_owned(),
130 ]);
131 out.emit(&Report {
132 schema: "rk.versions-check/2",
133 pins: results,
134 })
135}
136
137fn resolve_ref(action: &str, pinned_commit: &str) -> (&'static str, Option<String>) {
140 let Some((repo, reference)) = action.split_once('@') else {
141 return ("ref-unparsable", None);
142 };
143 let url = format!("https://api.github.com/repos/{repo}/commits/{reference}");
144 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
145 let fetched = std::process::Command::new(curl)
146 .args(["-fsSL", "--max-time", "10", &url])
147 .output();
148 let body = match fetched {
149 Ok(output) if output.status.success() => output.stdout,
150 _ => return ("ref-unreachable", None),
151 };
152 let Some(sha) = serde_json::from_slice::<serde_json::Value>(&body)
153 .ok()
154 .and_then(|value| {
155 value
156 .get("sha")
157 .and_then(serde_json::Value::as_str)
158 .map(str::to_owned)
159 })
160 else {
161 return ("ref-unparsable", None);
162 };
163 if sha == pinned_commit {
164 ("ref-unmoved", Some(sha))
165 } else {
166 ("ref-moved", Some(sha))
167 }
168}
169
170fn check_one(tool: &str, pinned: &str, url: &str) -> PinResult {
172 let result = |result, available| PinResult {
173 tool: tool.to_owned(),
174 pinned: pinned.to_owned(),
175 result,
176 available,
177 commit: None,
178 ref_class: None,
179 ref_result: None,
180 ref_commit: None,
181 };
182 let curl = std::env::var_os("RK_CURL_BIN").unwrap_or_else(|| "curl".into());
183 let fetched = std::process::Command::new(curl)
184 .args(["-fsSL", "--max-time", "10", url])
185 .output();
186 let body = match fetched {
187 Ok(output) if output.status.success() => output.stdout,
188 _ => return result("source-unreachable", None),
189 };
190 let Some(available) = latest_version(&body) else {
191 return result("source-unparsable", None);
192 };
193 if is_current(pinned, &available) {
194 result("current", Some(available))
195 } else {
196 result("update-available", Some(available))
197 }
198}
199
200fn latest_version(body: &[u8]) -> Option<String> {
203 let value: serde_json::Value = serde_json::from_slice(body).ok()?;
204 let raw = value
205 .get("crate")
206 .and_then(|krate| krate.get("max_stable_version"))
207 .or_else(|| value.get("tag_name"))
208 .and_then(serde_json::Value::as_str)?;
209 let start = raw.find(|c: char| c.is_ascii_digit())?;
212 Some(raw[start..].to_owned())
213}
214
215fn is_current(pinned: &str, available: &str) -> bool {
218 if pinned == available {
219 return true;
220 }
221 !pinned.contains('.') && available.split('.').next() == Some(pinned)
222}
223
224#[cfg(test)]
225mod tests {
226 #![allow(clippy::expect_used)]
227
228 use super::{PinResult, Report, is_current, latest_version};
229
230 #[test]
231 fn a_source_version_is_read_from_both_answer_shapes() {
232 assert_eq!(
233 latest_version(br#"{"crate":{"max_stable_version":"0.3.170"}}"#),
234 Some("0.3.170".to_owned())
235 );
236 assert_eq!(
237 latest_version(br#"{"tag_name":"v2.13.1"}"#),
238 Some("2.13.1".to_owned())
239 );
240 assert_eq!(
241 latest_version(br#"{"tag_name":"release-plz-v0.3.160"}"#),
242 Some("0.3.160".to_owned())
243 );
244 assert_eq!(latest_version(b"not json"), None);
245 assert_eq!(latest_version(br#"{"unrelated":true}"#), None);
246 }
247
248 #[test]
249 fn a_major_only_pin_is_current_within_its_major() {
250 assert!(is_current("0.3.160", "0.3.160"));
251 assert!(!is_current("0.3.160", "0.3.170"));
252 assert!(is_current("4", "4.3.1"));
253 assert!(!is_current("4", "5.0.0"));
254 }
255
256 #[test]
258 fn the_versions_check_schema_snapshot_holds() {
259 let report = Report {
260 schema: "rk.versions-check/2",
261 pins: vec![PinResult {
262 tool: "release-plz".into(),
263 pinned: "0.3.160".into(),
264 result: "update-available",
265 available: Some("0.3.170".into()),
266 commit: Some("2eb1d8bcb770b4c48ccfaad919734b38b51958c9".into()),
267 ref_class: Some("moving-minor-tag".into()),
268 ref_result: Some("ref-unmoved"),
269 ref_commit: Some("2eb1d8bcb770b4c48ccfaad919734b38b51958c9".into()),
270 }],
271 };
272 assert_eq!(
273 serde_json::to_string(&report).expect("a report serializes"),
274 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"}]}"#
275 );
276 }
277}