1use serde::Serialize;
17
18use crate::cli::status::StatusArgs;
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::digest::Digest;
21use crate::error::RkError;
22use crate::landing::invariants::{self, InvariantFailure};
23use crate::landing::manifest::{self, Alignment, Manifest};
24use crate::landing::{self, Kind};
25use crate::output::Output;
26use crate::projection::{Candidate, Projection, ProjectionInput, TargetEvidence};
27use crate::{embedded, registry};
28
29#[derive(Debug, Serialize)]
31struct Drift {
32 rendered: usize,
34 seeded: usize,
36}
37
38#[derive(Debug, Serialize)]
40struct StalePin {
41 tool: String,
43 landed: String,
45 available: String,
47}
48
49#[derive(Debug, serde::Serialize)]
51struct ConfigState {
52 state: &'static str,
53 pending: Vec<String>,
54}
55
56fn config_state(config: Option<&crate::config::Config>, record: Option<&Manifest>) -> ConfigState {
57 let pending = config
58 .zip(record)
59 .map_or_else(Vec::new, |(config, record)| {
60 crate::config::pending(config, record)
61 });
62 ConfigState {
63 state: if config.is_none() {
64 "absent"
65 } else if pending.is_empty() {
66 "aligned"
67 } else {
68 "pending"
69 },
70 pending,
71 }
72}
73
74#[derive(Debug, Serialize)]
76struct Report {
77 schema: &'static str,
79 landed: bool,
81 config: ConfigState,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 tech: Option<String>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 forge: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 workflow: Option<&'static str>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 style: Option<&'static str>,
92 #[serde(skip_serializing_if = "Option::is_none")]
95 nix: Option<bool>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 rk_version: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 binary_version: Option<&'static str>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 alignment: Option<Alignment>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 drift: Option<Drift>,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 missing: Option<Vec<String>>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 stale_pins: Option<Vec<StalePin>>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 sentinels: Option<usize>,
112 #[serde(skip_serializing_if = "Option::is_none")]
116 record_drift: Option<usize>,
117 #[serde(skip_serializing_if = "Option::is_none")]
120 invariant_failures: Option<Vec<InvariantFailure>>,
121 #[serde(skip_serializing_if = "Option::is_none")]
127 pending: Option<usize>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 violations: Option<Vec<String>>,
131}
132
133fn report_absent(
134 out: Output,
135 args: &StatusArgs,
136 config: Option<&crate::config::Config>,
137) -> Result<(), RkError> {
138 out.result_line(format!("config: {}", config_state(config, None).state));
139 out.result_line(format!("no landing at {}", args.target));
140 out.next(&[
141 format!(
142 "rk init --tech <tech> --target {} lands the workflow",
143 args.target
144 ),
145 format!(
146 "rk adopt --target {} records a landing made before the receipt existed",
147 args.target
148 ),
149 format!(
150 "rk stage --target {} stages this binary's candidate; the rk-setup skill carries the best-effort migration",
151 args.target
152 ),
153 ]);
154 out.emit(&Report {
155 schema: "rk.status/9",
156 landed: false,
157 config: config_state(config, None),
158 tech: None,
159 forge: None,
160 workflow: None,
161 style: None,
162 nix: None,
163 rk_version: None,
164 binary_version: None,
165 alignment: None,
166 drift: None,
167 missing: None,
168 stale_pins: None,
169 sentinels: None,
170 record_drift: None,
171 invariant_failures: None,
172 pending: None,
173 violations: args.check.then(|| vec!["no landing".to_owned()]),
174 })?;
175 if args.check {
176 return Err(RkError::check_failed(
177 Diagnostic::new(
178 Reason::StateDrift,
179 format!("no landing at {}, and --check requires one", args.target),
180 )
181 .expected("a target carrying .release-kit/manifest.json")
182 .action("rk init lands the workflow; rk adopt records an existing landing; rk stage and the rk-setup skill carry the migration"),
183 ));
184 }
185 Ok(())
186}
187
188struct Observed {
190 drift_rendered: Vec<String>,
191 drift_seeded: Vec<String>,
192 parameter_drift: Vec<String>,
195 record_drift: Vec<String>,
200 missing: Vec<String>,
201 stale: Vec<StalePin>,
202 sentinels: Vec<(String, usize, String)>,
203 invariants: Vec<InvariantFailure>,
204 pending: Option<Vec<String>>,
208}
209
210pub fn run(args: &StatusArgs) -> Result<(), RkError> {
219 let out = Output::new(args.json);
220 if !args.target.is_dir() {
221 return Err(RkError::missing(
222 Diagnostic::new(
223 Reason::TargetNotFound,
224 format!("target {} is not a directory", args.target),
225 )
226 .expected("an existing repository to report on"),
227 ));
228 }
229 let config = crate::config::load(args.target.as_std_path())?;
230 let Some(manifest) = manifest::load(&args.target)? else {
231 return report_absent(out, args, config.as_ref());
232 };
233
234 let config = config_state(config.as_ref(), Some(&manifest));
235 out.result_line(format!("config: {}", config.state));
236 for key in &config.pending {
237 out.result_line(format!(
238 "config pending: {key}; rk upgrade --apply takes it up"
239 ));
240 }
241 let observed = observe(args, &manifest)?;
242 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
243 render_human(out, args, &manifest, alignment, &observed);
244
245 let violations = violations_of(&observed);
246 out.emit(&Report {
247 schema: "rk.status/9",
248 landed: true,
249 config,
250 tech: Some(manifest.tech),
251 forge: Some(manifest.forge),
252 workflow: Some(manifest.parameters.workflow.as_str()),
253 style: manifest.parameters.style.map(manifest::Style::as_str),
254 nix: Some(manifest.parameters.nix),
255 rk_version: Some(manifest.rk_version),
256 binary_version: Some(env!("CARGO_PKG_VERSION")),
257 alignment: Some(alignment),
258 drift: Some(Drift {
259 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
260 seeded: observed.drift_seeded.len(),
261 }),
262 record_drift: Some(observed.record_drift.len()),
263 missing: Some(observed.missing.clone()),
264 stale_pins: Some(observed.stale),
265 sentinels: Some(observed.sentinels.len()),
266 invariant_failures: Some(observed.invariants),
267 pending: observed.pending.as_ref().map(Vec::len),
268 violations: args.check.then(|| violations.clone()),
269 })?;
270
271 if args.check && !violations.is_empty() {
272 return Err(RkError::check_failed(
273 Diagnostic::new(
274 Reason::StateDrift,
275 format!(
276 "the landing is not clean: {} violation{}",
277 violations.len(),
278 if violations.len() == 1 { "" } else { "s" }
279 ),
280 )
281 .expected(
282 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
283 ),
284 ));
285 }
286 Ok(())
287}
288
289fn violations_of(observed: &Observed) -> Vec<String> {
293 observed
294 .drift_rendered
295 .iter()
296 .map(|path| format!("rendered drift: {path}"))
297 .chain(
298 observed
299 .parameter_drift
300 .iter()
301 .map(|path| format!("parameter drift: {path}")),
302 )
303 .chain(
304 observed
305 .record_drift
306 .iter()
307 .map(|reason| format!("record drift: {reason}")),
308 )
309 .chain(
310 observed
311 .missing
312 .iter()
313 .map(|path| format!("missing: {path}")),
314 )
315 .chain(
316 observed
317 .sentinels
318 .iter()
319 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
320 )
321 .chain(
322 observed
323 .invariants
324 .iter()
325 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
326 )
327 .collect()
328}
329
330fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
333 let mut observed = Observed {
334 drift_rendered: Vec::new(),
335 drift_seeded: Vec::new(),
336 parameter_drift: Vec::new(),
337 record_drift: Vec::new(),
338 missing: Vec::new(),
339 stale: Vec::new(),
340 sentinels: Vec::new(),
341 invariants: Vec::new(),
342 pending: None,
343 };
344 let aligned =
350 manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION")) == Alignment::Aligned;
351 let projected = match project(args, manifest) {
352 Ok(projection) => Some(projection),
353 Err(err) if aligned => return Err(err),
354 Err(_) => None,
355 };
356 for file in &manifest.files {
357 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
358 observed.missing.push(file.destination.clone());
359 continue;
360 };
361 if Digest::of(&bytes) != file.sha256 {
362 match file.kind {
363 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
364 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
365 Kind::State => {}
366 }
367 }
368 observed.invariants.extend(invariants::failures(
369 &manifest.tech,
370 &manifest.forge,
371 &file.destination,
372 &bytes,
373 ));
374 let text = String::from_utf8_lossy(&bytes);
375 for (idx, line) in text.lines().enumerate() {
376 if line.contains(embedded::SENTINEL) {
377 observed.sentinels.push((
378 file.destination.clone(),
379 idx + 1,
380 line.trim().to_owned(),
381 ));
382 }
383 }
384 let defective = projected.as_ref().is_some_and(|projection| {
389 projection
390 .collisions
391 .iter()
392 .any(|collision| collision.destination == file.destination)
393 });
394 if defective && !observed.drift_rendered.contains(&file.destination) {
395 observed.drift_rendered.push(file.destination.clone());
396 }
397 }
398 observed.invariants.extend(invariants::target_failures(
403 &manifest.tech,
404 &manifest.forge,
405 &args.target,
406 ));
407 if aligned && let Some(projection) = projected.as_ref() {
408 observe_parameter_drift(manifest, projection, &mut observed);
409 observe_record_set(manifest, projection, &mut observed.record_drift);
410 }
411 observed.pending = projected
416 .as_ref()
417 .map(|projection| pending_of(manifest, &projection.candidates));
418 for (tool, landed) in &manifest.pins {
422 if let Some(available) = registry::version_of(tool)
423 && manifest::version_is_newer(&available, landed)
424 {
425 observed.stale.push(StalePin {
426 tool: tool.clone(),
427 landed: landed.clone(),
428 available,
429 });
430 }
431 }
432 Ok(observed)
433}
434
435fn observe_parameter_drift(manifest: &Manifest, projection: &Projection, observed: &mut Observed) {
447 for candidate in &projection.candidates {
448 if candidate.kind != Kind::Rendered {
449 continue;
450 }
451 let Some(record) = manifest.file(&candidate.destination) else {
452 continue;
453 };
454 if observed.drift_rendered.contains(&candidate.destination)
455 || observed.missing.contains(&candidate.destination)
456 {
457 continue;
458 }
459 if Digest::of(recorded_form(candidate)) != record.sha256 {
460 observed
461 .parameter_drift
462 .push(format!("{} (parameters)", candidate.destination));
463 }
464 }
465}
466
467fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Projection, RkError> {
471 let evidence = TargetEvidence::gather(&args.target, Some(manifest))?;
472 Projection::compute(&ProjectionInput {
473 params: landing::Params::from_record(manifest),
474 evidence,
475 })
476}
477
478fn recorded_form(candidate: &Candidate) -> &[u8] {
481 candidate.region.as_deref().unwrap_or(&candidate.bytes)
482}
483
484fn pending_of(manifest: &Manifest, projected: &[Candidate]) -> Vec<String> {
493 let mut pending = Vec::new();
494 for entry in projected {
495 let changed = manifest.file(&entry.destination).is_none_or(|record| {
496 record.kind != entry.kind
497 || (entry.kind == Kind::Rendered
498 && record.sha256 != Digest::of(recorded_form(entry)))
499 });
500 if changed {
501 pending.push(entry.destination.clone());
502 }
503 }
504 for file in &manifest.files {
505 if !projected
506 .iter()
507 .any(|entry| entry.destination == file.destination)
508 {
509 pending.push(file.destination.clone());
510 }
511 }
512 pending.sort();
513 pending.dedup();
514 pending
515}
516
517fn observe_record_set(
529 manifest: &Manifest,
530 projection: &Projection,
531 record_drift: &mut Vec<String>,
532) {
533 for entry in &projection.candidates {
534 if manifest.file(&entry.destination).is_none() {
535 record_drift.push(format!(
536 "the recorded parameters project {}, which the receipt does not name",
537 entry.destination
538 ));
539 }
540 }
541 for file in &manifest.files {
542 let produced = projection
543 .candidates
544 .iter()
545 .any(|entry| entry.destination == file.destination)
546 || projection
547 .omissions
548 .iter()
549 .any(|omission| omission.destination == file.destination);
550 if !produced {
551 record_drift.push(format!(
552 "the receipt names {}, which the recorded parameters do not project",
553 file.destination
554 ));
555 }
556 }
557}
558
559fn render_human(
561 out: Output,
562 args: &StatusArgs,
563 manifest: &Manifest,
564 alignment: Alignment,
565 observed: &Observed,
566) {
567 out.result_line(format!(
568 "release-kit {} ({}, {}, {} workflow, {} style{}) at {}",
569 manifest.rk_version,
570 manifest.tech,
571 manifest.forge,
572 manifest.parameters.workflow.as_str(),
573 manifest
574 .parameters
575 .style
576 .map_or("unrecorded", manifest::Style::as_str),
577 if manifest.parameters.nix { ", nix" } else { "" },
578 args.target
579 ));
580 if alignment == Alignment::TargetNewer {
581 out.result_line(format!(
582 "binary {} is older than this landing; install the matching rk",
583 env!("CARGO_PKG_VERSION")
584 ));
585 }
586 match observed.pending.as_deref() {
587 None => out.result_line(format!(
588 "this binary carries no {}/{} projection, so what an upgrade would change is unknown",
589 manifest.tech, manifest.forge
590 )),
591 Some(paths) => {
592 for path in paths {
593 out.result_line(format!("PENDING {path} (this binary would change it)"));
594 }
595 }
596 }
597 for path in &observed.drift_rendered {
598 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
599 }
600 for path in &observed.parameter_drift {
601 out.result_line(format!(
602 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
603 ));
604 }
605 for reason in &observed.record_drift {
606 out.result_line(format!("DRIFT record: {reason}"));
607 }
608 for path in &observed.drift_seeded {
609 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
610 }
611 for path in &observed.missing {
612 out.result_line(format!("MISSING {path}"));
613 }
614 for pin in &observed.stale {
615 out.result_line(format!(
616 "STALE {} {} landed, {} in this binary",
617 pin.tool, pin.landed, pin.available
618 ));
619 }
620 for (path, line, text) in &observed.sentinels {
621 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
622 }
623 for failure in &observed.invariants {
624 out.result_line(format!(
625 "INVARIANT {} ({}): {}",
626 failure.destination, failure.code, failure.reason
627 ));
628 }
629 let mut next = Vec::new();
630 for failure in &observed.invariants {
631 next.push(format!("{}: {}", failure.destination, failure.remediation));
632 }
633 if !observed.record_drift.is_empty() {
634 next.push(format!(
635 "rk upgrade --target {} rewrites the receipt from its parameters",
636 args.target
637 ));
638 }
639 if observed
640 .pending
641 .as_deref()
642 .is_none_or(|paths| !paths.is_empty())
643 {
644 next.push(format!(
645 "rk upgrade --target {} takes this landing to {}",
646 args.target,
647 env!("CARGO_PKG_VERSION")
648 ));
649 }
650 next.push(format!(
651 "rk status --check --target {} exits 1 on a violation",
652 args.target
653 ));
654 out.next(&next);
655}
656
657#[cfg(test)]
658mod tests {
659 use super::{Drift, InvariantFailure, Report, StalePin};
660
661 #[test]
664 fn the_status_report_schema_snapshot_holds() {
665 let landed = Report {
666 schema: "rk.status/9",
667 landed: true,
668 config: super::ConfigState {
669 state: "pending",
670 pending: vec!["landing.style".into()],
671 },
672 tech: Some("rust".into()),
673 forge: Some("github".into()),
674 workflow: Some("worktree"),
675 style: Some("trunk"),
676 nix: Some(true),
677 rk_version: Some("0.1.0".into()),
678 binary_version: Some("0.2.0"),
679 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
680 drift: Some(Drift {
681 rendered: 0,
682 seeded: 1,
683 }),
684 missing: Some(vec![]),
685 stale_pins: Some(vec![StalePin {
686 tool: "release-plz".into(),
687 landed: "0.3.160".into(),
688 available: "0.3.170".into(),
689 }]),
690 sentinels: Some(1),
691 record_drift: Some(0),
692 invariant_failures: Some(vec![InvariantFailure {
693 code: "attestations-disabled",
694 destination: "dist-workspace.toml".into(),
695 reason: "github-attestations is not effectively true".into(),
696 remediation: "set github-attestations = true in [dist]",
697 }]),
698 pending: Some(2),
699 violations: None,
700 };
701 assert_eq!(
702 serde_json::to_string(&landed).expect("a report serializes"),
703 r#"{"schema":"rk.status/9","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}"#
704 );
705 let absent = Report {
706 landed: false,
707 config: super::ConfigState {
708 state: "absent",
709 pending: vec![],
710 },
711 tech: None,
712 forge: None,
713 workflow: None,
714 style: None,
715 nix: None,
716 rk_version: None,
717 binary_version: None,
718 alignment: None,
719 drift: None,
720 missing: None,
721 stale_pins: None,
722 sentinels: None,
723 record_drift: None,
724 invariant_failures: None,
725 pending: None,
726 violations: None,
727 ..landed
728 };
729 assert_eq!(
730 serde_json::to_string(&absent).expect("a report serializes"),
731 r#"{"schema":"rk.status/9","landed":false,"config":{"state":"absent","pending":[]}}"#,
732 "an absent landing reports one field a caller can branch on"
733 );
734 }
735}