1use serde::Serialize;
12
13use crate::cli::status::StatusArgs;
14use crate::diagnostic::{Diagnostic, Reason};
15use crate::digest::Digest;
16use crate::error::RkError;
17use crate::landing::invariants::{self, InvariantFailure};
18use crate::landing::manifest::{self, Alignment, Manifest};
19use crate::landing::{self, Kind};
20use crate::output::Output;
21use crate::{embedded, registry};
22
23#[derive(Debug, Serialize)]
25struct Drift {
26 rendered: usize,
28 seeded: usize,
30}
31
32#[derive(Debug, Serialize)]
34struct StalePin {
35 tool: String,
37 landed: String,
39 available: String,
41}
42
43#[derive(Debug, Serialize)]
45struct Report {
46 schema: &'static str,
48 landed: bool,
50 #[serde(skip_serializing_if = "Option::is_none")]
51 tech: Option<String>,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 forge: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 rk_version: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 binary_version: Option<&'static str>,
58 #[serde(skip_serializing_if = "Option::is_none")]
59 alignment: Option<Alignment>,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 drift: Option<Drift>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 missing: Option<Vec<String>>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 stale_pins: Option<Vec<StalePin>>,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 sentinels: Option<usize>,
70 #[serde(skip_serializing_if = "Option::is_none")]
73 invariant_failures: Option<Vec<InvariantFailure>>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 violations: Option<Vec<String>>,
77}
78
79struct Observed {
81 drift_rendered: Vec<String>,
82 drift_seeded: Vec<String>,
83 missing: Vec<String>,
84 stale: Vec<StalePin>,
85 sentinels: Vec<(String, usize, String)>,
86 invariants: Vec<InvariantFailure>,
87}
88
89pub fn run(args: &StatusArgs) -> Result<(), RkError> {
98 let out = Output::new(args.json);
99 if !args.target.is_dir() {
100 return Err(RkError::missing(
101 Diagnostic::new(
102 Reason::TargetNotFound,
103 format!("target {} is not a directory", args.target),
104 )
105 .expected("an existing repository to report on"),
106 ));
107 }
108 let Some(manifest) = manifest::load(&args.target)? else {
109 out.result_line(format!("no landing at {}", args.target));
110 out.next(&[
111 format!(
112 "rk init --tech <tech> --target {} lands the workflow",
113 args.target
114 ),
115 format!(
116 "rk adopt --target {} records a landing made before the record existed",
117 args.target
118 ),
119 ]);
120 out.emit(&Report {
121 schema: "rk.status/2",
122 landed: false,
123 tech: None,
124 forge: None,
125 rk_version: None,
126 binary_version: None,
127 alignment: None,
128 drift: None,
129 missing: None,
130 stale_pins: None,
131 sentinels: None,
132 invariant_failures: None,
133 violations: args.check.then(|| vec!["no landing".to_owned()]),
134 })?;
135 if args.check {
136 return Err(RkError::check_failed(
137 Diagnostic::new(
138 Reason::StateDrift,
139 format!("no landing at {}, and --check requires one", args.target),
140 )
141 .expected("a target carrying .release-kit/manifest.json")
142 .action("rk init lands the workflow; rk adopt records an existing landing"),
143 ));
144 }
145 return Ok(());
146 };
147
148 let observed = observe(args, &manifest)?;
149 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
150 render_human(out, args, &manifest, alignment, &observed);
151
152 let violations = violations_of(&observed);
153 out.emit(&Report {
154 schema: "rk.status/2",
155 landed: true,
156 tech: Some(manifest.tech),
157 forge: Some(manifest.forge),
158 rk_version: Some(manifest.rk_version),
159 binary_version: Some(env!("CARGO_PKG_VERSION")),
160 alignment: Some(alignment),
161 drift: Some(Drift {
162 rendered: observed.drift_rendered.len(),
163 seeded: observed.drift_seeded.len(),
164 }),
165 missing: Some(observed.missing.clone()),
166 stale_pins: Some(observed.stale),
167 sentinels: Some(observed.sentinels.len()),
168 invariant_failures: Some(observed.invariants),
169 violations: args.check.then(|| violations.clone()),
170 })?;
171
172 if args.check && !violations.is_empty() {
173 return Err(RkError::check_failed(
174 Diagnostic::new(
175 Reason::StateDrift,
176 format!(
177 "the landing is not clean: {} violation{}",
178 violations.len(),
179 if violations.len() == 1 { "" } else { "s" }
180 ),
181 )
182 .expected(
183 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
184 ),
185 ));
186 }
187 Ok(())
188}
189
190fn violations_of(observed: &Observed) -> Vec<String> {
194 observed
195 .drift_rendered
196 .iter()
197 .map(|path| format!("rendered drift: {path}"))
198 .chain(
199 observed
200 .missing
201 .iter()
202 .map(|path| format!("missing: {path}")),
203 )
204 .chain(
205 observed
206 .sentinels
207 .iter()
208 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
209 )
210 .chain(
211 observed
212 .invariants
213 .iter()
214 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
215 )
216 .collect()
217}
218
219fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
222 let mut observed = Observed {
223 drift_rendered: Vec::new(),
224 drift_seeded: Vec::new(),
225 missing: Vec::new(),
226 stale: Vec::new(),
227 sentinels: Vec::new(),
228 invariants: Vec::new(),
229 };
230 for file in &manifest.files {
231 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
232 observed.missing.push(file.destination.clone());
233 continue;
234 };
235 if Digest::of(&bytes) != file.sha256 {
236 match file.kind {
237 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
238 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
239 Kind::State => {}
240 }
241 }
242 observed.invariants.extend(invariants::failures(
243 &manifest.tech,
244 &manifest.forge,
245 &file.destination,
246 &bytes,
247 ));
248 let text = String::from_utf8_lossy(&bytes);
249 for (idx, line) in text.lines().enumerate() {
250 if line.contains(embedded::SENTINEL) {
251 observed.sentinels.push((
252 file.destination.clone(),
253 idx + 1,
254 line.trim().to_owned(),
255 ));
256 }
257 }
258 if file.destination == landing::HOOKS_DESTINATION
262 && !observed.drift_rendered.contains(&file.destination)
263 && landing::hooks_file_defect(&args.target)?.is_some()
264 {
265 observed.drift_rendered.push(file.destination.clone());
266 }
267 }
268 for (tool, landed) in &manifest.pins {
272 if let Some(available) = registry::version_of(tool) {
273 if manifest::version_is_newer(&available, landed) {
274 observed.stale.push(StalePin {
275 tool: tool.clone(),
276 landed: landed.clone(),
277 available,
278 });
279 }
280 }
281 }
282 Ok(observed)
283}
284
285fn render_human(
287 out: Output,
288 args: &StatusArgs,
289 manifest: &Manifest,
290 alignment: Alignment,
291 observed: &Observed,
292) {
293 out.result_line(format!(
294 "release-kit {} ({}, {}) at {}",
295 manifest.rk_version, manifest.tech, manifest.forge, args.target
296 ));
297 match alignment {
298 Alignment::BinaryNewer => out.result_line(format!(
299 "binary {} is newer; run 'rk upgrade'",
300 env!("CARGO_PKG_VERSION")
301 )),
302 Alignment::TargetNewer => out.result_line(format!(
303 "binary {} is older than this landing; install the matching rk",
304 env!("CARGO_PKG_VERSION")
305 )),
306 Alignment::Aligned => {}
307 }
308 for path in &observed.drift_rendered {
309 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
310 }
311 for path in &observed.drift_seeded {
312 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
313 }
314 for path in &observed.missing {
315 out.result_line(format!("MISSING {path}"));
316 }
317 for pin in &observed.stale {
318 out.result_line(format!(
319 "STALE {} {} landed, {} in this binary",
320 pin.tool, pin.landed, pin.available
321 ));
322 }
323 for (path, line, text) in &observed.sentinels {
324 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
325 }
326 for failure in &observed.invariants {
327 out.result_line(format!(
328 "INVARIANT {} ({}): {}",
329 failure.destination, failure.code, failure.reason
330 ));
331 }
332 let mut next = Vec::new();
333 for failure in &observed.invariants {
334 next.push(format!("{}: {}", failure.destination, failure.remediation));
335 }
336 if alignment == Alignment::BinaryNewer {
337 next.push(format!(
338 "rk upgrade --target {} takes this landing to {}",
339 args.target,
340 env!("CARGO_PKG_VERSION")
341 ));
342 }
343 next.push(format!(
344 "rk status --check --target {} exits 1 on a violation",
345 args.target
346 ));
347 out.next(&next);
348}
349
350#[cfg(test)]
351mod tests {
352 #![allow(clippy::expect_used)]
353
354 use super::{Drift, InvariantFailure, Report, StalePin};
355
356 #[test]
359 fn the_status_report_schema_snapshot_holds() {
360 let landed = Report {
361 schema: "rk.status/2",
362 landed: true,
363 tech: Some("rust".into()),
364 forge: Some("github".into()),
365 rk_version: Some("0.1.0".into()),
366 binary_version: Some("0.2.0"),
367 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
368 drift: Some(Drift {
369 rendered: 0,
370 seeded: 1,
371 }),
372 missing: Some(vec![]),
373 stale_pins: Some(vec![StalePin {
374 tool: "release-plz".into(),
375 landed: "0.3.160".into(),
376 available: "0.3.170".into(),
377 }]),
378 sentinels: Some(1),
379 invariant_failures: Some(vec![InvariantFailure {
380 code: "attestations-disabled",
381 destination: "dist-workspace.toml".into(),
382 reason: "github-attestations is not effectively true".into(),
383 remediation: "set github-attestations = true in [dist]",
384 }]),
385 violations: None,
386 };
387 assert_eq!(
388 serde_json::to_string(&landed).expect("a report serializes"),
389 r#"{"schema":"rk.status/2","landed":true,"tech":"rust","forge":"github","rk_version":"0.1.0","binary_version":"0.2.0","alignment":"binary-newer","drift":{"rendered":0,"seeded":1},"missing":[],"stale_pins":[{"tool":"release-plz","landed":"0.3.160","available":"0.3.170"}],"sentinels":1,"invariant_failures":[{"code":"attestations-disabled","destination":"dist-workspace.toml","reason":"github-attestations is not effectively true","remediation":"set github-attestations = true in [dist]"}]}"#
390 );
391 let absent = Report {
392 landed: false,
393 tech: None,
394 forge: None,
395 rk_version: None,
396 binary_version: None,
397 alignment: None,
398 drift: None,
399 missing: None,
400 stale_pins: None,
401 sentinels: None,
402 invariant_failures: None,
403 violations: None,
404 ..landed
405 };
406 assert_eq!(
407 serde_json::to_string(&absent).expect("a report serializes"),
408 r#"{"schema":"rk.status/2","landed":false}"#,
409 "an absent landing reports one field a caller can branch on"
410 );
411 }
412}