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, Entry, 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, serde::Serialize)]
45struct ConfigState {
46 state: &'static str,
47 pending: Vec<String>,
48}
49
50fn config_state(config: Option<&crate::config::Config>, record: Option<&Manifest>) -> ConfigState {
51 let pending = config
52 .zip(record)
53 .map_or_else(Vec::new, |(config, record)| {
54 crate::config::pending(config, record)
55 });
56 ConfigState {
57 state: if config.is_none() {
58 "absent"
59 } else if pending.is_empty() {
60 "aligned"
61 } else {
62 "pending"
63 },
64 pending,
65 }
66}
67
68#[derive(Debug, Serialize)]
70struct Report {
71 schema: &'static str,
73 landed: bool,
75 config: ConfigState,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 tech: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 forge: Option<String>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 workflow: Option<&'static str>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 style: Option<&'static str>,
86 #[serde(skip_serializing_if = "Option::is_none")]
89 nix: Option<bool>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 rk_version: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 binary_version: Option<&'static str>,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 alignment: Option<Alignment>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 drift: Option<Drift>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 missing: Option<Vec<String>>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 stale_pins: Option<Vec<StalePin>>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 sentinels: Option<usize>,
106 #[serde(skip_serializing_if = "Option::is_none")]
110 record_drift: Option<usize>,
111 #[serde(skip_serializing_if = "Option::is_none")]
114 invariant_failures: Option<Vec<InvariantFailure>>,
115 #[serde(skip_serializing_if = "Option::is_none")]
121 pending: Option<usize>,
122 #[serde(skip_serializing_if = "Option::is_none")]
124 violations: Option<Vec<String>>,
125}
126
127fn report_absent(
128 out: Output,
129 args: &StatusArgs,
130 config: Option<&crate::config::Config>,
131) -> Result<(), RkError> {
132 out.result_line(format!("config: {}", config_state(config, None).state));
133 out.result_line(format!("no landing at {}", args.target));
134 out.next(&[
135 format!(
136 "rk init --tech <tech> --target {} lands the workflow",
137 args.target
138 ),
139 format!(
140 "rk adopt --target {} records a landing made before the record existed",
141 args.target
142 ),
143 ]);
144 out.emit(&Report {
145 schema: "rk.status/8",
146 landed: false,
147 config: config_state(config, None),
148 tech: None,
149 forge: None,
150 workflow: None,
151 style: None,
152 nix: None,
153 rk_version: None,
154 binary_version: None,
155 alignment: None,
156 drift: None,
157 missing: None,
158 stale_pins: None,
159 sentinels: None,
160 record_drift: None,
161 invariant_failures: None,
162 pending: None,
163 violations: args.check.then(|| vec!["no landing".to_owned()]),
164 })?;
165 if args.check {
166 return Err(RkError::check_failed(
167 Diagnostic::new(
168 Reason::StateDrift,
169 format!("no landing at {}, and --check requires one", args.target),
170 )
171 .expected("a target carrying .release-kit/manifest.json")
172 .action("rk init lands the workflow; rk adopt records an existing landing"),
173 ));
174 }
175 Ok(())
176}
177
178struct Observed {
180 drift_rendered: Vec<String>,
181 drift_seeded: Vec<String>,
182 parameter_drift: Vec<String>,
185 record_drift: Vec<String>,
190 missing: Vec<String>,
191 stale: Vec<StalePin>,
192 sentinels: Vec<(String, usize, String)>,
193 invariants: Vec<InvariantFailure>,
194 pending: Option<Vec<String>>,
197}
198
199pub fn run(args: &StatusArgs) -> Result<(), RkError> {
208 let out = Output::new(args.json);
209 if !args.target.is_dir() {
210 return Err(RkError::missing(
211 Diagnostic::new(
212 Reason::TargetNotFound,
213 format!("target {} is not a directory", args.target),
214 )
215 .expected("an existing repository to report on"),
216 ));
217 }
218 let config = crate::config::load(args.target.as_std_path())?;
219 let Some(manifest) = manifest::load(&args.target)? else {
220 return report_absent(out, args, config.as_ref());
221 };
222
223 let config = config_state(config.as_ref(), Some(&manifest));
224 out.result_line(format!("config: {}", config.state));
225 for key in &config.pending {
226 out.result_line(format!(
227 "config pending: {key}; rk upgrade --apply takes it up"
228 ));
229 }
230 let observed = observe(args, &manifest)?;
231 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
232 render_human(out, args, &manifest, alignment, &observed);
233
234 let violations = violations_of(&observed);
235 out.emit(&Report {
236 schema: "rk.status/8",
237 landed: true,
238 config,
239 tech: Some(manifest.tech),
240 forge: Some(manifest.forge),
241 workflow: Some(manifest.parameters.workflow.as_str()),
242 style: manifest.parameters.style.map(manifest::Style::as_str),
243 nix: Some(manifest.parameters.nix),
244 rk_version: Some(manifest.rk_version),
245 binary_version: Some(env!("CARGO_PKG_VERSION")),
246 alignment: Some(alignment),
247 drift: Some(Drift {
248 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
249 seeded: observed.drift_seeded.len(),
250 }),
251 record_drift: Some(observed.record_drift.len()),
252 missing: Some(observed.missing.clone()),
253 stale_pins: Some(observed.stale),
254 sentinels: Some(observed.sentinels.len()),
255 invariant_failures: Some(observed.invariants),
256 pending: observed.pending.as_ref().map(Vec::len),
257 violations: args.check.then(|| violations.clone()),
258 })?;
259
260 if args.check && !violations.is_empty() {
261 return Err(RkError::check_failed(
262 Diagnostic::new(
263 Reason::StateDrift,
264 format!(
265 "the landing is not clean: {} violation{}",
266 violations.len(),
267 if violations.len() == 1 { "" } else { "s" }
268 ),
269 )
270 .expected(
271 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
272 ),
273 ));
274 }
275 Ok(())
276}
277
278fn violations_of(observed: &Observed) -> Vec<String> {
282 observed
283 .drift_rendered
284 .iter()
285 .map(|path| format!("rendered drift: {path}"))
286 .chain(
287 observed
288 .parameter_drift
289 .iter()
290 .map(|path| format!("parameter drift: {path}")),
291 )
292 .chain(
293 observed
294 .record_drift
295 .iter()
296 .map(|reason| format!("record drift: {reason}")),
297 )
298 .chain(
299 observed
300 .missing
301 .iter()
302 .map(|path| format!("missing: {path}")),
303 )
304 .chain(
305 observed
306 .sentinels
307 .iter()
308 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
309 )
310 .chain(
311 observed
312 .invariants
313 .iter()
314 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
315 )
316 .collect()
317}
318
319fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
322 let mut observed = Observed {
323 drift_rendered: Vec::new(),
324 drift_seeded: Vec::new(),
325 parameter_drift: Vec::new(),
326 record_drift: Vec::new(),
327 missing: Vec::new(),
328 stale: Vec::new(),
329 sentinels: Vec::new(),
330 invariants: Vec::new(),
331 pending: None,
332 };
333 for file in &manifest.files {
334 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
335 observed.missing.push(file.destination.clone());
336 continue;
337 };
338 if Digest::of(&bytes) != file.sha256 {
339 match file.kind {
340 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
341 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
342 Kind::State => {}
343 }
344 }
345 observed.invariants.extend(invariants::failures(
346 &manifest.tech,
347 &manifest.forge,
348 &file.destination,
349 &bytes,
350 ));
351 let text = String::from_utf8_lossy(&bytes);
352 for (idx, line) in text.lines().enumerate() {
353 if line.contains(embedded::SENTINEL) {
354 observed.sentinels.push((
355 file.destination.clone(),
356 idx + 1,
357 line.trim().to_owned(),
358 ));
359 }
360 }
361 if file.destination == landing::HOOKS_DESTINATION
365 && !observed.drift_rendered.contains(&file.destination)
366 && landing::hooks_file_defect(&args.target)?.is_some()
367 {
368 observed.drift_rendered.push(file.destination.clone());
369 }
370 }
371 observed.invariants.extend(invariants::target_failures(
376 &manifest.tech,
377 &manifest.forge,
378 &args.target,
379 ));
380 let same_payload = manifest.payload_sha256 == crate::commands::payload::report().payload_sha256;
381 let projected = match project(args, manifest) {
387 Ok(entries) => Some(entries),
388 Err(err) if same_payload => return Err(err),
389 Err(_) => None,
390 };
391 if same_payload {
392 observe_parameter_drift(manifest, &mut observed);
393 if let Some(entries) = projected.as_deref() {
394 observe_record_set(manifest, entries, &mut observed.record_drift);
395 }
396 }
397 observed.pending = projected
402 .as_deref()
403 .map(|entries| pending_of(manifest, entries));
404 for (tool, landed) in &manifest.pins {
408 if let Some(available) = registry::version_of(tool) {
409 if manifest::version_is_newer(&available, landed) {
410 observed.stale.push(StalePin {
411 tool: tool.clone(),
412 landed: landed.clone(),
413 available,
414 });
415 }
416 }
417 }
418 Ok(observed)
419}
420
421fn observe_parameter_drift(manifest: &Manifest, observed: &mut Observed) {
433 let params = landing::Params::from_record(manifest);
434 for (destination, template) in [
435 (
436 landing::AGENTS_DESTINATION,
437 landing::routing_block(params.workflow()),
438 ),
439 (
440 landing::HOOKS_DESTINATION,
441 landing::hooks_block(params.workflow()),
442 ),
443 ] {
444 let Some(record) = manifest.file(destination) else {
445 continue;
446 };
447 if observed
448 .drift_rendered
449 .iter()
450 .any(|path| path == destination)
451 || observed.missing.iter().any(|path| path == destination)
452 {
453 continue;
454 }
455 let candidate = landing::render(template.as_bytes(), ¶ms);
456 if Digest::of(&candidate) != record.sha256 {
457 observed
458 .parameter_drift
459 .push(format!("{destination} (parameters.workflow)"));
460 }
461 }
462}
463
464fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Vec<Entry>, RkError> {
468 let mut projected = landing::projection(&landing::Params::from_record(manifest))?;
469 landing::withhold_nix(
470 &args.target,
471 manifest.parameters.nix,
472 Some(manifest),
473 &mut projected,
474 )?;
475 Ok(projected)
476}
477
478fn pending_of(manifest: &Manifest, projected: &[Entry]) -> Vec<String> {
487 let mut pending = Vec::new();
488 for entry in projected {
489 let changed = manifest.file(&entry.destination).is_none_or(|record| {
490 record.kind != entry.kind
491 || (entry.kind == Kind::Rendered && record.sha256 != Digest::of(&entry.rendered))
492 });
493 if changed {
494 pending.push(entry.destination.clone());
495 }
496 }
497 for file in &manifest.files {
498 if !projected
499 .iter()
500 .any(|entry| entry.destination == file.destination)
501 {
502 pending.push(file.destination.clone());
503 }
504 }
505 pending.sort();
506 pending.dedup();
507 pending
508}
509
510fn observe_record_set(manifest: &Manifest, projected: &[Entry], record_drift: &mut Vec<String>) {
520 for entry in projected {
521 if manifest.file(&entry.destination).is_none() {
522 record_drift.push(format!(
523 "the recorded parameters project {}, which the record does not name",
524 entry.destination
525 ));
526 }
527 }
528 for file in &manifest.files {
529 if !projected
530 .iter()
531 .any(|entry| entry.destination == file.destination)
532 {
533 record_drift.push(format!(
534 "the record names {}, which the recorded parameters do not project",
535 file.destination
536 ));
537 }
538 }
539}
540
541fn render_human(
543 out: Output,
544 args: &StatusArgs,
545 manifest: &Manifest,
546 alignment: Alignment,
547 observed: &Observed,
548) {
549 out.result_line(format!(
550 "release-kit {} ({}, {}, {} workflow, {} style{}) at {}",
551 manifest.rk_version,
552 manifest.tech,
553 manifest.forge,
554 manifest.parameters.workflow.as_str(),
555 manifest
556 .parameters
557 .style
558 .map_or("unrecorded", manifest::Style::as_str),
559 if manifest.parameters.nix { ", nix" } else { "" },
560 args.target
561 ));
562 if alignment == Alignment::TargetNewer {
563 out.result_line(format!(
564 "binary {} is older than this landing; install the matching rk",
565 env!("CARGO_PKG_VERSION")
566 ));
567 }
568 match observed.pending.as_deref() {
569 None => out.result_line(format!(
570 "this binary carries no {}/{} payload, so what an upgrade would change is unknown",
571 manifest.tech, manifest.forge
572 )),
573 Some(paths) => {
574 for path in paths {
575 out.result_line(format!("PENDING {path} (this payload would change it)"));
576 }
577 }
578 }
579 for path in &observed.drift_rendered {
580 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
581 }
582 for path in &observed.parameter_drift {
583 out.result_line(format!(
584 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
585 ));
586 }
587 for reason in &observed.record_drift {
588 out.result_line(format!("DRIFT record: {reason}"));
589 }
590 for path in &observed.drift_seeded {
591 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
592 }
593 for path in &observed.missing {
594 out.result_line(format!("MISSING {path}"));
595 }
596 for pin in &observed.stale {
597 out.result_line(format!(
598 "STALE {} {} landed, {} in this binary",
599 pin.tool, pin.landed, pin.available
600 ));
601 }
602 for (path, line, text) in &observed.sentinels {
603 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
604 }
605 for failure in &observed.invariants {
606 out.result_line(format!(
607 "INVARIANT {} ({}): {}",
608 failure.destination, failure.code, failure.reason
609 ));
610 }
611 let mut next = Vec::new();
612 for failure in &observed.invariants {
613 next.push(format!("{}: {}", failure.destination, failure.remediation));
614 }
615 if !observed.record_drift.is_empty() {
616 next.push(format!(
617 "rk upgrade --target {} reconciles the record with its parameters",
618 args.target
619 ));
620 }
621 if observed
622 .pending
623 .as_deref()
624 .is_none_or(|paths| !paths.is_empty())
625 {
626 next.push(format!(
627 "rk upgrade --target {} takes this landing to {}",
628 args.target,
629 env!("CARGO_PKG_VERSION")
630 ));
631 }
632 next.push(format!(
633 "rk status --check --target {} exits 1 on a violation",
634 args.target
635 ));
636 out.next(&next);
637}
638
639#[cfg(test)]
640mod tests {
641 use super::{Drift, InvariantFailure, Report, StalePin};
642
643 #[test]
646 fn the_status_report_schema_snapshot_holds() {
647 let landed = Report {
648 schema: "rk.status/8",
649 landed: true,
650 config: super::ConfigState {
651 state: "pending",
652 pending: vec!["landing.style".into()],
653 },
654 tech: Some("rust".into()),
655 forge: Some("github".into()),
656 workflow: Some("worktree"),
657 style: Some("trunk"),
658 nix: Some(true),
659 rk_version: Some("0.1.0".into()),
660 binary_version: Some("0.2.0"),
661 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
662 drift: Some(Drift {
663 rendered: 0,
664 seeded: 1,
665 }),
666 missing: Some(vec![]),
667 stale_pins: Some(vec![StalePin {
668 tool: "release-plz".into(),
669 landed: "0.3.160".into(),
670 available: "0.3.170".into(),
671 }]),
672 sentinels: Some(1),
673 record_drift: Some(0),
674 invariant_failures: Some(vec![InvariantFailure {
675 code: "attestations-disabled",
676 destination: "dist-workspace.toml".into(),
677 reason: "github-attestations is not effectively true".into(),
678 remediation: "set github-attestations = true in [dist]",
679 }]),
680 pending: Some(2),
681 violations: None,
682 };
683 assert_eq!(
684 serde_json::to_string(&landed).expect("a report serializes"),
685 r#"{"schema":"rk.status/8","landed":true,"config":{"state":"pending","pending":["landing.style"]},"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]"}],"pending":2}"#
686 );
687 let absent = Report {
688 landed: false,
689 config: super::ConfigState {
690 state: "absent",
691 pending: vec![],
692 },
693 tech: None,
694 forge: None,
695 workflow: None,
696 style: None,
697 nix: None,
698 rk_version: None,
699 binary_version: None,
700 alignment: None,
701 drift: None,
702 missing: None,
703 stale_pins: None,
704 sentinels: None,
705 record_drift: None,
706 invariant_failures: None,
707 pending: None,
708 violations: None,
709 ..landed
710 };
711 assert_eq!(
712 serde_json::to_string(&absent).expect("a report serializes"),
713 r#"{"schema":"rk.status/8","landed":false,"config":{"state":"absent","pending":[]}}"#,
714 "an absent landing reports one field a caller can branch on"
715 );
716 }
717}