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