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")]
56 workflow: Option<&'static str>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 rk_version: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 binary_version: Option<&'static str>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 alignment: Option<Alignment>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 drift: Option<Drift>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 missing: Option<Vec<String>>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 stale_pins: Option<Vec<StalePin>>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 sentinels: Option<usize>,
73 #[serde(skip_serializing_if = "Option::is_none")]
76 invariant_failures: Option<Vec<InvariantFailure>>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 violations: Option<Vec<String>>,
80}
81
82struct Observed {
84 drift_rendered: Vec<String>,
85 drift_seeded: Vec<String>,
86 parameter_drift: Vec<String>,
89 missing: Vec<String>,
90 stale: Vec<StalePin>,
91 sentinels: Vec<(String, usize, String)>,
92 invariants: Vec<InvariantFailure>,
93}
94
95pub fn run(args: &StatusArgs) -> Result<(), RkError> {
104 let out = Output::new(args.json);
105 if !args.target.is_dir() {
106 return Err(RkError::missing(
107 Diagnostic::new(
108 Reason::TargetNotFound,
109 format!("target {} is not a directory", args.target),
110 )
111 .expected("an existing repository to report on"),
112 ));
113 }
114 let Some(manifest) = manifest::load(&args.target)? else {
115 out.result_line(format!("no landing at {}", args.target));
116 out.next(&[
117 format!(
118 "rk init --tech <tech> --target {} lands the workflow",
119 args.target
120 ),
121 format!(
122 "rk adopt --target {} records a landing made before the record existed",
123 args.target
124 ),
125 ]);
126 out.emit(&Report {
127 schema: "rk.status/3",
128 landed: false,
129 tech: None,
130 forge: None,
131 workflow: None,
132 rk_version: None,
133 binary_version: None,
134 alignment: None,
135 drift: None,
136 missing: None,
137 stale_pins: None,
138 sentinels: None,
139 invariant_failures: None,
140 violations: args.check.then(|| vec!["no landing".to_owned()]),
141 })?;
142 if args.check {
143 return Err(RkError::check_failed(
144 Diagnostic::new(
145 Reason::StateDrift,
146 format!("no landing at {}, and --check requires one", args.target),
147 )
148 .expected("a target carrying .release-kit/manifest.json")
149 .action("rk init lands the workflow; rk adopt records an existing landing"),
150 ));
151 }
152 return Ok(());
153 };
154
155 let observed = observe(args, &manifest)?;
156 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
157 render_human(out, args, &manifest, alignment, &observed);
158
159 let violations = violations_of(&observed);
160 out.emit(&Report {
161 schema: "rk.status/3",
162 landed: true,
163 tech: Some(manifest.tech),
164 forge: Some(manifest.forge),
165 workflow: Some(manifest.parameters.workflow.as_str()),
166 rk_version: Some(manifest.rk_version),
167 binary_version: Some(env!("CARGO_PKG_VERSION")),
168 alignment: Some(alignment),
169 drift: Some(Drift {
170 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
171 seeded: observed.drift_seeded.len(),
172 }),
173 missing: Some(observed.missing.clone()),
174 stale_pins: Some(observed.stale),
175 sentinels: Some(observed.sentinels.len()),
176 invariant_failures: Some(observed.invariants),
177 violations: args.check.then(|| violations.clone()),
178 })?;
179
180 if args.check && !violations.is_empty() {
181 return Err(RkError::check_failed(
182 Diagnostic::new(
183 Reason::StateDrift,
184 format!(
185 "the landing is not clean: {} violation{}",
186 violations.len(),
187 if violations.len() == 1 { "" } else { "s" }
188 ),
189 )
190 .expected(
191 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
192 ),
193 ));
194 }
195 Ok(())
196}
197
198fn violations_of(observed: &Observed) -> Vec<String> {
202 observed
203 .drift_rendered
204 .iter()
205 .map(|path| format!("rendered drift: {path}"))
206 .chain(
207 observed
208 .parameter_drift
209 .iter()
210 .map(|path| format!("parameter drift: {path}")),
211 )
212 .chain(
213 observed
214 .missing
215 .iter()
216 .map(|path| format!("missing: {path}")),
217 )
218 .chain(
219 observed
220 .sentinels
221 .iter()
222 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
223 )
224 .chain(
225 observed
226 .invariants
227 .iter()
228 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
229 )
230 .collect()
231}
232
233fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
236 let mut observed = Observed {
237 drift_rendered: Vec::new(),
238 drift_seeded: Vec::new(),
239 parameter_drift: Vec::new(),
240 missing: Vec::new(),
241 stale: Vec::new(),
242 sentinels: Vec::new(),
243 invariants: Vec::new(),
244 };
245 for file in &manifest.files {
246 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
247 observed.missing.push(file.destination.clone());
248 continue;
249 };
250 if Digest::of(&bytes) != file.sha256 {
251 match file.kind {
252 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
253 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
254 Kind::State => {}
255 }
256 }
257 observed.invariants.extend(invariants::failures(
258 &manifest.tech,
259 &manifest.forge,
260 &file.destination,
261 &bytes,
262 ));
263 let text = String::from_utf8_lossy(&bytes);
264 for (idx, line) in text.lines().enumerate() {
265 if line.contains(embedded::SENTINEL) {
266 observed.sentinels.push((
267 file.destination.clone(),
268 idx + 1,
269 line.trim().to_owned(),
270 ));
271 }
272 }
273 if file.destination == landing::HOOKS_DESTINATION
277 && !observed.drift_rendered.contains(&file.destination)
278 && landing::hooks_file_defect(&args.target)?.is_some()
279 {
280 observed.drift_rendered.push(file.destination.clone());
281 }
282 }
283 let same_payload = manifest.payload_sha256 == crate::commands::payload::report().payload_sha256;
294 for (destination, template) in [
295 (
296 landing::AGENTS_DESTINATION,
297 landing::routing_block(manifest.parameters.workflow),
298 ),
299 (
300 landing::HOOKS_DESTINATION,
301 landing::hooks_block(manifest.parameters.workflow),
302 ),
303 ] {
304 if !same_payload {
305 break;
306 }
307 let Some(record) = manifest.file(destination) else {
308 continue;
309 };
310 if observed
311 .drift_rendered
312 .iter()
313 .any(|path| path == destination)
314 || observed.missing.iter().any(|path| path == destination)
315 {
316 continue;
317 }
318 let candidate = landing::render(
319 template.as_bytes(),
320 &manifest.parameters.repo,
321 &manifest.parameters.scopes,
322 );
323 if Digest::of(&candidate) != record.sha256 {
324 observed
325 .parameter_drift
326 .push(format!("{destination} (parameters.workflow)"));
327 }
328 }
329 for (tool, landed) in &manifest.pins {
333 if let Some(available) = registry::version_of(tool) {
334 if manifest::version_is_newer(&available, landed) {
335 observed.stale.push(StalePin {
336 tool: tool.clone(),
337 landed: landed.clone(),
338 available,
339 });
340 }
341 }
342 }
343 Ok(observed)
344}
345
346fn render_human(
348 out: Output,
349 args: &StatusArgs,
350 manifest: &Manifest,
351 alignment: Alignment,
352 observed: &Observed,
353) {
354 out.result_line(format!(
355 "release-kit {} ({}, {}, {} workflow) at {}",
356 manifest.rk_version,
357 manifest.tech,
358 manifest.forge,
359 manifest.parameters.workflow.as_str(),
360 args.target
361 ));
362 match alignment {
363 Alignment::BinaryNewer => out.result_line(format!(
364 "binary {} is newer; run 'rk upgrade'",
365 env!("CARGO_PKG_VERSION")
366 )),
367 Alignment::TargetNewer => out.result_line(format!(
368 "binary {} is older than this landing; install the matching rk",
369 env!("CARGO_PKG_VERSION")
370 )),
371 Alignment::Aligned => {}
372 }
373 for path in &observed.drift_rendered {
374 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
375 }
376 for path in &observed.parameter_drift {
377 out.result_line(format!(
378 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
379 ));
380 }
381 for path in &observed.drift_seeded {
382 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
383 }
384 for path in &observed.missing {
385 out.result_line(format!("MISSING {path}"));
386 }
387 for pin in &observed.stale {
388 out.result_line(format!(
389 "STALE {} {} landed, {} in this binary",
390 pin.tool, pin.landed, pin.available
391 ));
392 }
393 for (path, line, text) in &observed.sentinels {
394 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
395 }
396 for failure in &observed.invariants {
397 out.result_line(format!(
398 "INVARIANT {} ({}): {}",
399 failure.destination, failure.code, failure.reason
400 ));
401 }
402 let mut next = Vec::new();
403 for failure in &observed.invariants {
404 next.push(format!("{}: {}", failure.destination, failure.remediation));
405 }
406 if alignment == Alignment::BinaryNewer {
407 next.push(format!(
408 "rk upgrade --target {} takes this landing to {}",
409 args.target,
410 env!("CARGO_PKG_VERSION")
411 ));
412 }
413 next.push(format!(
414 "rk status --check --target {} exits 1 on a violation",
415 args.target
416 ));
417 out.next(&next);
418}
419
420#[cfg(test)]
421mod tests {
422 #![allow(clippy::expect_used)]
423
424 use super::{Drift, InvariantFailure, Report, StalePin};
425
426 #[test]
429 fn the_status_report_schema_snapshot_holds() {
430 let landed = Report {
431 schema: "rk.status/3",
432 landed: true,
433 tech: Some("rust".into()),
434 forge: Some("github".into()),
435 workflow: Some("worktree"),
436 rk_version: Some("0.1.0".into()),
437 binary_version: Some("0.2.0"),
438 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
439 drift: Some(Drift {
440 rendered: 0,
441 seeded: 1,
442 }),
443 missing: Some(vec![]),
444 stale_pins: Some(vec![StalePin {
445 tool: "release-plz".into(),
446 landed: "0.3.160".into(),
447 available: "0.3.170".into(),
448 }]),
449 sentinels: Some(1),
450 invariant_failures: Some(vec![InvariantFailure {
451 code: "attestations-disabled",
452 destination: "dist-workspace.toml".into(),
453 reason: "github-attestations is not effectively true".into(),
454 remediation: "set github-attestations = true in [dist]",
455 }]),
456 violations: None,
457 };
458 assert_eq!(
459 serde_json::to_string(&landed).expect("a report serializes"),
460 r#"{"schema":"rk.status/3","landed":true,"tech":"rust","forge":"github","workflow":"worktree","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]"}]}"#
461 );
462 let absent = Report {
463 landed: false,
464 tech: None,
465 forge: None,
466 workflow: None,
467 rk_version: None,
468 binary_version: None,
469 alignment: None,
470 drift: None,
471 missing: None,
472 stale_pins: None,
473 sentinels: None,
474 invariant_failures: None,
475 violations: None,
476 ..landed
477 };
478 assert_eq!(
479 serde_json::to_string(&absent).expect("a report serializes"),
480 r#"{"schema":"rk.status/3","landed":false}"#,
481 "an absent landing reports one field a caller can branch on"
482 );
483 }
484}