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")]
59 style: Option<&'static str>,
60 #[serde(skip_serializing_if = "Option::is_none")]
63 nix: Option<bool>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 rk_version: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 binary_version: Option<&'static str>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 alignment: Option<Alignment>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 drift: Option<Drift>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 missing: Option<Vec<String>>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 stale_pins: Option<Vec<StalePin>>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 sentinels: Option<usize>,
80 #[serde(skip_serializing_if = "Option::is_none")]
84 record_drift: Option<usize>,
85 #[serde(skip_serializing_if = "Option::is_none")]
88 invariant_failures: Option<Vec<InvariantFailure>>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 violations: Option<Vec<String>>,
92}
93
94struct Observed {
96 drift_rendered: Vec<String>,
97 drift_seeded: Vec<String>,
98 parameter_drift: Vec<String>,
101 record_drift: Vec<String>,
106 missing: Vec<String>,
107 stale: Vec<StalePin>,
108 sentinels: Vec<(String, usize, String)>,
109 invariants: Vec<InvariantFailure>,
110}
111
112pub fn run(args: &StatusArgs) -> Result<(), RkError> {
121 let out = Output::new(args.json);
122 if !args.target.is_dir() {
123 return Err(RkError::missing(
124 Diagnostic::new(
125 Reason::TargetNotFound,
126 format!("target {} is not a directory", args.target),
127 )
128 .expected("an existing repository to report on"),
129 ));
130 }
131 let Some(manifest) = manifest::load(&args.target)? else {
132 out.result_line(format!("no landing at {}", args.target));
133 out.next(&[
134 format!(
135 "rk init --tech <tech> --target {} lands the workflow",
136 args.target
137 ),
138 format!(
139 "rk adopt --target {} records a landing made before the record existed",
140 args.target
141 ),
142 ]);
143 out.emit(&Report {
144 schema: "rk.status/6",
145 landed: false,
146 tech: None,
147 forge: None,
148 workflow: None,
149 style: None,
150 nix: None,
151 rk_version: None,
152 binary_version: None,
153 alignment: None,
154 drift: None,
155 missing: None,
156 stale_pins: None,
157 sentinels: None,
158 record_drift: None,
159 invariant_failures: None,
160 violations: args.check.then(|| vec!["no landing".to_owned()]),
161 })?;
162 if args.check {
163 return Err(RkError::check_failed(
164 Diagnostic::new(
165 Reason::StateDrift,
166 format!("no landing at {}, and --check requires one", args.target),
167 )
168 .expected("a target carrying .release-kit/manifest.json")
169 .action("rk init lands the workflow; rk adopt records an existing landing"),
170 ));
171 }
172 return Ok(());
173 };
174
175 let observed = observe(args, &manifest)?;
176 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
177 render_human(out, args, &manifest, alignment, &observed);
178
179 let violations = violations_of(&observed);
180 out.emit(&Report {
181 schema: "rk.status/6",
182 landed: true,
183 tech: Some(manifest.tech),
184 forge: Some(manifest.forge),
185 workflow: Some(manifest.parameters.workflow.as_str()),
186 style: manifest.parameters.style.map(manifest::Style::as_str),
187 nix: Some(manifest.parameters.nix),
188 rk_version: Some(manifest.rk_version),
189 binary_version: Some(env!("CARGO_PKG_VERSION")),
190 alignment: Some(alignment),
191 drift: Some(Drift {
192 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
193 seeded: observed.drift_seeded.len(),
194 }),
195 record_drift: Some(observed.record_drift.len()),
196 missing: Some(observed.missing.clone()),
197 stale_pins: Some(observed.stale),
198 sentinels: Some(observed.sentinels.len()),
199 invariant_failures: Some(observed.invariants),
200 violations: args.check.then(|| violations.clone()),
201 })?;
202
203 if args.check && !violations.is_empty() {
204 return Err(RkError::check_failed(
205 Diagnostic::new(
206 Reason::StateDrift,
207 format!(
208 "the landing is not clean: {} violation{}",
209 violations.len(),
210 if violations.len() == 1 { "" } else { "s" }
211 ),
212 )
213 .expected(
214 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
215 ),
216 ));
217 }
218 Ok(())
219}
220
221fn violations_of(observed: &Observed) -> Vec<String> {
225 observed
226 .drift_rendered
227 .iter()
228 .map(|path| format!("rendered drift: {path}"))
229 .chain(
230 observed
231 .parameter_drift
232 .iter()
233 .map(|path| format!("parameter drift: {path}")),
234 )
235 .chain(
236 observed
237 .record_drift
238 .iter()
239 .map(|reason| format!("record drift: {reason}")),
240 )
241 .chain(
242 observed
243 .missing
244 .iter()
245 .map(|path| format!("missing: {path}")),
246 )
247 .chain(
248 observed
249 .sentinels
250 .iter()
251 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
252 )
253 .chain(
254 observed
255 .invariants
256 .iter()
257 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
258 )
259 .collect()
260}
261
262fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
265 let mut observed = Observed {
266 drift_rendered: Vec::new(),
267 drift_seeded: Vec::new(),
268 parameter_drift: Vec::new(),
269 record_drift: Vec::new(),
270 missing: Vec::new(),
271 stale: Vec::new(),
272 sentinels: Vec::new(),
273 invariants: Vec::new(),
274 };
275 for file in &manifest.files {
276 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
277 observed.missing.push(file.destination.clone());
278 continue;
279 };
280 if Digest::of(&bytes) != file.sha256 {
281 match file.kind {
282 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
283 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
284 Kind::State => {}
285 }
286 }
287 observed.invariants.extend(invariants::failures(
288 &manifest.tech,
289 &manifest.forge,
290 &file.destination,
291 &bytes,
292 ));
293 let text = String::from_utf8_lossy(&bytes);
294 for (idx, line) in text.lines().enumerate() {
295 if line.contains(embedded::SENTINEL) {
296 observed.sentinels.push((
297 file.destination.clone(),
298 idx + 1,
299 line.trim().to_owned(),
300 ));
301 }
302 }
303 if file.destination == landing::HOOKS_DESTINATION
307 && !observed.drift_rendered.contains(&file.destination)
308 && landing::hooks_file_defect(&args.target)?.is_some()
309 {
310 observed.drift_rendered.push(file.destination.clone());
311 }
312 }
313 observed.invariants.extend(invariants::target_failures(
318 &manifest.tech,
319 &manifest.forge,
320 &args.target,
321 ));
322 let same_payload = manifest.payload_sha256 == crate::commands::payload::report().payload_sha256;
323 if same_payload {
324 observe_parameter_drift(manifest, &mut observed);
325 }
326 if same_payload {
327 observe_record_set(args, manifest, &mut observed.record_drift)?;
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 observe_parameter_drift(manifest: &Manifest, observed: &mut Observed) {
358 for (destination, template) in [
359 (
360 landing::AGENTS_DESTINATION,
361 landing::routing_block(manifest.parameters.workflow),
362 ),
363 (
364 landing::HOOKS_DESTINATION,
365 landing::hooks_block(manifest.parameters.workflow),
366 ),
367 ] {
368 let Some(record) = manifest.file(destination) else {
369 continue;
370 };
371 if observed
372 .drift_rendered
373 .iter()
374 .any(|path| path == destination)
375 || observed.missing.iter().any(|path| path == destination)
376 {
377 continue;
378 }
379 let candidate = landing::render(
380 template.as_bytes(),
381 &manifest.parameters.repo,
382 &manifest.parameters.scopes,
383 manifest.parameters.style,
384 );
385 if Digest::of(&candidate) != record.sha256 {
386 observed
387 .parameter_drift
388 .push(format!("{destination} (parameters.workflow)"));
389 }
390 }
391}
392
393fn observe_record_set(
403 args: &StatusArgs,
404 manifest: &Manifest,
405 record_drift: &mut Vec<String>,
406) -> Result<(), RkError> {
407 let mut projected = landing::projection(
408 &manifest.tech,
409 &manifest.forge,
410 &manifest.parameters.repo,
411 &manifest.parameters.scopes,
412 manifest.parameters.workflow,
413 manifest.parameters.style,
414 manifest.parameters.nix,
415 )?;
416 landing::withhold_nix(
417 &args.target,
418 manifest.parameters.nix,
419 Some(manifest),
420 &mut projected,
421 )?;
422 for entry in &projected {
423 if manifest.file(&entry.destination).is_none() {
424 record_drift.push(format!(
425 "the recorded parameters project {}, which the record does not name",
426 entry.destination
427 ));
428 }
429 }
430 for file in &manifest.files {
431 if !projected
432 .iter()
433 .any(|entry| entry.destination == file.destination)
434 {
435 record_drift.push(format!(
436 "the record names {}, which the recorded parameters do not project",
437 file.destination
438 ));
439 }
440 }
441 Ok(())
442}
443
444fn render_human(
446 out: Output,
447 args: &StatusArgs,
448 manifest: &Manifest,
449 alignment: Alignment,
450 observed: &Observed,
451) {
452 out.result_line(format!(
453 "release-kit {} ({}, {}, {} workflow, {} style{}) at {}",
454 manifest.rk_version,
455 manifest.tech,
456 manifest.forge,
457 manifest.parameters.workflow.as_str(),
458 manifest
459 .parameters
460 .style
461 .map_or("unrecorded", manifest::Style::as_str),
462 if manifest.parameters.nix { ", nix" } else { "" },
463 args.target
464 ));
465 match alignment {
466 Alignment::BinaryNewer => out.result_line(format!(
467 "binary {} is newer; run 'rk upgrade'",
468 env!("CARGO_PKG_VERSION")
469 )),
470 Alignment::TargetNewer => out.result_line(format!(
471 "binary {} is older than this landing; install the matching rk",
472 env!("CARGO_PKG_VERSION")
473 )),
474 Alignment::Aligned => {}
475 }
476 for path in &observed.drift_rendered {
477 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
478 }
479 for path in &observed.parameter_drift {
480 out.result_line(format!(
481 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
482 ));
483 }
484 for reason in &observed.record_drift {
485 out.result_line(format!("DRIFT record: {reason}"));
486 }
487 for path in &observed.drift_seeded {
488 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
489 }
490 for path in &observed.missing {
491 out.result_line(format!("MISSING {path}"));
492 }
493 for pin in &observed.stale {
494 out.result_line(format!(
495 "STALE {} {} landed, {} in this binary",
496 pin.tool, pin.landed, pin.available
497 ));
498 }
499 for (path, line, text) in &observed.sentinels {
500 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
501 }
502 for failure in &observed.invariants {
503 out.result_line(format!(
504 "INVARIANT {} ({}): {}",
505 failure.destination, failure.code, failure.reason
506 ));
507 }
508 let mut next = Vec::new();
509 for failure in &observed.invariants {
510 next.push(format!("{}: {}", failure.destination, failure.remediation));
511 }
512 if !observed.record_drift.is_empty() {
513 next.push(format!(
514 "rk upgrade --target {} reconciles the record with its parameters",
515 args.target
516 ));
517 }
518 if alignment == Alignment::BinaryNewer {
519 next.push(format!(
520 "rk upgrade --target {} takes this landing to {}",
521 args.target,
522 env!("CARGO_PKG_VERSION")
523 ));
524 }
525 next.push(format!(
526 "rk status --check --target {} exits 1 on a violation",
527 args.target
528 ));
529 out.next(&next);
530}
531
532#[cfg(test)]
533mod tests {
534 #![allow(clippy::expect_used)]
535
536 use super::{Drift, InvariantFailure, Report, StalePin};
537
538 #[test]
541 fn the_status_report_schema_snapshot_holds() {
542 let landed = Report {
543 schema: "rk.status/6",
544 landed: true,
545 tech: Some("rust".into()),
546 forge: Some("github".into()),
547 workflow: Some("worktree"),
548 style: Some("trunk"),
549 nix: Some(true),
550 rk_version: Some("0.1.0".into()),
551 binary_version: Some("0.2.0"),
552 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
553 drift: Some(Drift {
554 rendered: 0,
555 seeded: 1,
556 }),
557 missing: Some(vec![]),
558 stale_pins: Some(vec![StalePin {
559 tool: "release-plz".into(),
560 landed: "0.3.160".into(),
561 available: "0.3.170".into(),
562 }]),
563 sentinels: Some(1),
564 record_drift: Some(0),
565 invariant_failures: Some(vec![InvariantFailure {
566 code: "attestations-disabled",
567 destination: "dist-workspace.toml".into(),
568 reason: "github-attestations is not effectively true".into(),
569 remediation: "set github-attestations = true in [dist]",
570 }]),
571 violations: None,
572 };
573 assert_eq!(
574 serde_json::to_string(&landed).expect("a report serializes"),
575 r#"{"schema":"rk.status/6","landed":true,"tech":"rust","forge":"github","workflow":"worktree","style":"trunk","nix":true,"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,"record_drift":0,"invariant_failures":[{"code":"attestations-disabled","destination":"dist-workspace.toml","reason":"github-attestations is not effectively true","remediation":"set github-attestations = true in [dist]"}]}"#
576 );
577 let absent = Report {
578 landed: false,
579 tech: None,
580 forge: None,
581 workflow: None,
582 style: None,
583 nix: None,
584 rk_version: None,
585 binary_version: None,
586 alignment: None,
587 drift: None,
588 missing: None,
589 stale_pins: None,
590 sentinels: None,
591 record_drift: None,
592 invariant_failures: None,
593 violations: None,
594 ..landed
595 };
596 assert_eq!(
597 serde_json::to_string(&absent).expect("a report serializes"),
598 r#"{"schema":"rk.status/6","landed":false}"#,
599 "an absent landing reports one field a caller can branch on"
600 );
601 }
602}