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