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, Serialize)]
55struct Warning {
56 code: &'static str,
58 reason: String,
60}
61
62const CODE_SCANNING_LICENCE: &str = "code-scanning-licence";
64
65#[derive(Debug, serde::Serialize)]
67struct ConfigState {
68 state: &'static str,
69 pending: Vec<String>,
70}
71
72fn config_state(config: Option<&crate::config::Config>, record: Option<&Manifest>) -> ConfigState {
73 let pending = config
74 .zip(record)
75 .map_or_else(Vec::new, |(config, record)| {
76 crate::config::pending(config, record)
77 });
78 ConfigState {
79 state: if config.is_none() {
80 "absent"
81 } else if pending.is_empty() {
82 "aligned"
83 } else {
84 "pending"
85 },
86 pending,
87 }
88}
89
90#[derive(Debug, Serialize)]
92struct Report {
93 schema: &'static str,
95 landed: bool,
97 config: ConfigState,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 tech: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 forge: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
104 workflow: Option<&'static str>,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 style: Option<&'static str>,
108 #[serde(skip_serializing_if = "Option::is_none")]
111 nix: Option<bool>,
112 #[serde(skip_serializing_if = "Option::is_none")]
115 scorecard: Option<bool>,
116 #[serde(skip_serializing_if = "Option::is_none")]
119 code_scanning: Option<&'static str>,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 rk_version: Option<String>,
122 #[serde(skip_serializing_if = "Option::is_none")]
123 binary_version: Option<&'static str>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 alignment: Option<Alignment>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 drift: Option<Drift>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 missing: Option<Vec<String>>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 stale_pins: Option<Vec<StalePin>>,
133 #[serde(skip_serializing_if = "Option::is_none")]
135 sentinels: Option<usize>,
136 #[serde(skip_serializing_if = "Option::is_none")]
140 record_drift: Option<usize>,
141 #[serde(skip_serializing_if = "Option::is_none")]
144 invariant_failures: Option<Vec<InvariantFailure>>,
145 #[serde(skip_serializing_if = "Option::is_none")]
149 warnings: Option<Vec<Warning>>,
150 #[serde(skip_serializing_if = "Option::is_none")]
156 pending: Option<usize>,
157 #[serde(skip_serializing_if = "Option::is_none")]
159 violations: Option<Vec<String>>,
160}
161
162fn report_absent(
163 out: Output,
164 args: &StatusArgs,
165 config: Option<&crate::config::Config>,
166) -> Result<(), RkError> {
167 out.result_line(format!("config: {}", config_state(config, None).state));
168 out.result_line(format!("no landing at {}", args.target));
169 out.next(&[
170 format!(
171 "rk init --tech <tech> --target {} lands the workflow",
172 args.target
173 ),
174 format!(
175 "rk adopt --target {} records a landing made before the receipt existed",
176 args.target
177 ),
178 format!(
179 "rk stage --target {} stages this binary's candidate; the rk-setup skill carries the best-effort migration",
180 args.target
181 ),
182 ]);
183 out.emit(&Report {
184 schema: "rk.status/11",
185 landed: false,
186 config: config_state(config, None),
187 tech: None,
188 forge: None,
189 workflow: None,
190 style: None,
191 nix: None,
192 scorecard: None,
193 code_scanning: None,
194 rk_version: None,
195 binary_version: None,
196 alignment: None,
197 drift: None,
198 missing: None,
199 stale_pins: None,
200 sentinels: None,
201 record_drift: None,
202 invariant_failures: None,
203 warnings: None,
204 pending: None,
205 violations: args.check.then(|| vec!["no landing".to_owned()]),
206 })?;
207 if args.check {
208 return Err(RkError::check_failed(
209 Diagnostic::new(
210 Reason::StateDrift,
211 format!("no landing at {}, and --check requires one", args.target),
212 )
213 .expected("a target carrying .release-kit/manifest.json")
214 .action("rk init lands the workflow; rk adopt records an existing landing; rk stage and the rk-setup skill carry the migration"),
215 ));
216 }
217 Ok(())
218}
219
220struct Observed {
222 drift_rendered: Vec<String>,
223 drift_seeded: Vec<String>,
224 parameter_drift: Vec<String>,
227 record_drift: Vec<String>,
232 missing: Vec<String>,
233 stale: Vec<StalePin>,
234 sentinels: Vec<(String, usize, String)>,
235 invariants: Vec<InvariantFailure>,
236 warnings: Vec<Warning>,
238 incompatible: Vec<String>,
242 pending: Option<Vec<String>>,
246}
247
248pub fn run(args: &StatusArgs) -> Result<(), RkError> {
257 let out = Output::new(args.json);
258 if !args.target.is_dir() {
259 return Err(RkError::missing(
260 Diagnostic::new(
261 Reason::TargetNotFound,
262 format!("target {} is not a directory", args.target),
263 )
264 .expected("an existing repository to report on"),
265 ));
266 }
267 let config = crate::config::load(args.target.as_std_path())?;
268 let Some(manifest) = manifest::load(&args.target)? else {
269 return report_absent(out, args, config.as_ref());
270 };
271
272 let config = config_state(config.as_ref(), Some(&manifest));
273 out.result_line(format!("config: {}", config.state));
274 for key in &config.pending {
275 out.result_line(format!(
276 "config pending: {key}; rk upgrade --apply takes it up"
277 ));
278 }
279 let observed = observe(args, &manifest)?;
280 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
281 render_human(out, args, &manifest, alignment, &observed);
282
283 let violations = violations_of(&observed);
284 out.emit(&Report {
285 schema: "rk.status/11",
286 landed: true,
287 config,
288 tech: Some(manifest.tech),
289 forge: Some(manifest.forge),
290 workflow: Some(manifest.parameters.workflow.as_str()),
291 style: manifest.parameters.style.map(manifest::Style::as_str),
292 nix: Some(manifest.parameters.nix),
293 scorecard: Some(manifest.parameters.scorecard),
294 code_scanning: manifest
295 .parameters
296 .code_scanning
297 .map(manifest::Provider::as_str),
298 rk_version: Some(manifest.rk_version),
299 binary_version: Some(env!("CARGO_PKG_VERSION")),
300 alignment: Some(alignment),
301 drift: Some(Drift {
302 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
303 seeded: observed.drift_seeded.len(),
304 }),
305 record_drift: Some(observed.record_drift.len()),
306 missing: Some(observed.missing.clone()),
307 stale_pins: Some(observed.stale),
308 sentinels: Some(observed.sentinels.len()),
309 invariant_failures: Some(observed.invariants),
310 warnings: Some(observed.warnings),
311 pending: observed.pending.as_ref().map(Vec::len),
312 violations: args.check.then(|| violations.clone()),
313 })?;
314
315 if args.check && !violations.is_empty() {
316 return Err(RkError::check_failed(
317 Diagnostic::new(
318 Reason::StateDrift,
319 format!(
320 "the landing is not clean: {} violation{}",
321 violations.len(),
322 if violations.len() == 1 { "" } else { "s" }
323 ),
324 )
325 .expected(
326 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
327 ),
328 ));
329 }
330 Ok(())
331}
332
333fn violations_of(observed: &Observed) -> Vec<String> {
337 observed
338 .drift_rendered
339 .iter()
340 .map(|path| format!("rendered drift: {path}"))
341 .chain(
342 observed
343 .parameter_drift
344 .iter()
345 .map(|path| format!("parameter drift: {path}")),
346 )
347 .chain(
348 observed
349 .record_drift
350 .iter()
351 .map(|reason| format!("record drift: {reason}")),
352 )
353 .chain(
354 observed
355 .missing
356 .iter()
357 .map(|path| format!("missing: {path}")),
358 )
359 .chain(
360 observed
361 .sentinels
362 .iter()
363 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
364 )
365 .chain(
366 observed
367 .invariants
368 .iter()
369 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
370 )
371 .collect()
372}
373
374fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
377 let mut observed = Observed {
378 drift_rendered: Vec::new(),
379 drift_seeded: Vec::new(),
380 parameter_drift: Vec::new(),
381 record_drift: Vec::new(),
382 missing: Vec::new(),
383 stale: Vec::new(),
384 sentinels: Vec::new(),
385 invariants: Vec::new(),
386 warnings: Vec::new(),
387 incompatible: Vec::new(),
388 pending: None,
389 };
390 let aligned =
396 manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION")) == Alignment::Aligned;
397 let projected = match project(args, manifest) {
398 Ok(projection) => Some(projection),
399 Err(err) if aligned => return Err(err),
400 Err(_) => None,
401 };
402 for file in &manifest.files {
403 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
404 observed.missing.push(file.destination.clone());
405 continue;
406 };
407 if Digest::of(&bytes) != file.sha256 {
408 match file.kind {
409 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
410 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
411 Kind::State => {}
412 }
413 }
414 observed.invariants.extend(invariants::failures(
415 &manifest.tech,
416 &manifest.forge,
417 &file.destination,
418 &bytes,
419 ));
420 let text = String::from_utf8_lossy(&bytes);
421 for (idx, line) in text.lines().enumerate() {
422 if line.contains(embedded::SENTINEL) {
423 observed.sentinels.push((
424 file.destination.clone(),
425 idx + 1,
426 line.trim().to_owned(),
427 ));
428 }
429 }
430 let defective = projected.as_ref().is_some_and(|projection| {
435 projection
436 .collisions
437 .iter()
438 .any(|collision| collision.destination == file.destination)
439 });
440 if defective && !observed.drift_rendered.contains(&file.destination) {
441 observed.drift_rendered.push(file.destination.clone());
442 }
443 }
444 observed.invariants.extend(invariants::target_failures(
449 &manifest.tech,
450 &manifest.forge,
451 &args.target,
452 ));
453 if let Some(reason) = projected
454 .as_ref()
455 .and_then(|projection| projection.licence_refusal.clone())
456 {
457 observed.warnings.push(Warning {
458 code: CODE_SCANNING_LICENCE,
459 reason,
460 });
461 }
462 if let Some(projection) = projected.as_ref() {
466 observed.incompatible.clone_from(&projection.record_defects);
467 observed
468 .record_drift
469 .extend(projection.record_defects.iter().cloned());
470 }
471 if aligned && let Some(projection) = projected.as_ref() {
472 observe_parameter_drift(manifest, projection, &mut observed);
473 observe_record_set(manifest, projection, &mut observed.record_drift);
474 }
475 observed.pending = projected
480 .as_ref()
481 .map(|projection| pending_of(manifest, &projection.candidates));
482 for (tool, landed) in &manifest.pins {
486 if let Some(available) = registry::version_of(tool)
487 && manifest::version_is_newer(&available, landed)
488 {
489 observed.stale.push(StalePin {
490 tool: tool.clone(),
491 landed: landed.clone(),
492 available,
493 });
494 }
495 }
496 Ok(observed)
497}
498
499fn observe_parameter_drift(manifest: &Manifest, projection: &Projection, observed: &mut Observed) {
511 for candidate in &projection.candidates {
512 if candidate.kind != Kind::Rendered {
513 continue;
514 }
515 let Some(record) = manifest.file(&candidate.destination) else {
516 continue;
517 };
518 if observed.drift_rendered.contains(&candidate.destination)
519 || observed.missing.contains(&candidate.destination)
520 {
521 continue;
522 }
523 if Digest::of(recorded_form(candidate)) != record.sha256 {
524 observed
525 .parameter_drift
526 .push(format!("{} (parameters)", candidate.destination));
527 }
528 }
529}
530
531fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Projection, RkError> {
535 let evidence = TargetEvidence::gather(&args.target, Some(manifest))?;
536 Projection::compute(&ProjectionInput {
537 params: landing::Params::from_record(manifest),
538 evidence,
539 })
540}
541
542fn recorded_form(candidate: &Candidate) -> &[u8] {
545 candidate.region.as_deref().unwrap_or(&candidate.bytes)
546}
547
548fn pending_of(manifest: &Manifest, projected: &[Candidate]) -> Vec<String> {
557 let mut pending = Vec::new();
558 for entry in projected {
559 let changed = manifest.file(&entry.destination).is_none_or(|record| {
560 record.kind != entry.kind
561 || (entry.kind == Kind::Rendered
562 && record.sha256 != Digest::of(recorded_form(entry)))
563 });
564 if changed {
565 pending.push(entry.destination.clone());
566 }
567 }
568 for file in &manifest.files {
569 if !projected
570 .iter()
571 .any(|entry| entry.destination == file.destination)
572 {
573 pending.push(file.destination.clone());
574 }
575 }
576 pending.sort();
577 pending.dedup();
578 pending
579}
580
581fn observe_record_set(
593 manifest: &Manifest,
594 projection: &Projection,
595 record_drift: &mut Vec<String>,
596) {
597 for entry in &projection.candidates {
598 if manifest.file(&entry.destination).is_none() {
599 record_drift.push(format!(
600 "the recorded parameters project {}, which the receipt does not name",
601 entry.destination
602 ));
603 }
604 }
605 for file in &manifest.files {
606 let produced = projection
607 .candidates
608 .iter()
609 .any(|entry| entry.destination == file.destination)
610 || projection
611 .omissions
612 .iter()
613 .any(|omission| omission.destination == file.destination);
614 if !produced {
615 record_drift.push(format!(
616 "the receipt names {}, which the recorded parameters do not project",
617 file.destination
618 ));
619 }
620 }
621}
622
623fn render_human(
625 out: Output,
626 args: &StatusArgs,
627 manifest: &Manifest,
628 alignment: Alignment,
629 observed: &Observed,
630) {
631 out.result_line(format!(
632 "release-kit {} ({}, {}, {} workflow, {} style{}) at {}",
633 manifest.rk_version,
634 manifest.tech,
635 manifest.forge,
636 manifest.parameters.workflow.as_str(),
637 manifest
638 .parameters
639 .style
640 .map_or("unrecorded", manifest::Style::as_str),
641 if manifest.parameters.nix { ", nix" } else { "" },
642 args.target
643 ));
644 if alignment == Alignment::TargetNewer {
645 out.result_line(format!(
646 "binary {} is older than this landing; install the matching rk",
647 env!("CARGO_PKG_VERSION")
648 ));
649 }
650 match observed.pending.as_deref() {
651 None => out.result_line(format!(
652 "this binary carries no {}/{} projection, so what an upgrade would change is unknown",
653 manifest.tech, manifest.forge
654 )),
655 Some(paths) => {
656 for path in paths {
657 out.result_line(format!("PENDING {path} (this binary would change it)"));
658 }
659 }
660 }
661 for path in &observed.drift_rendered {
662 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
663 }
664 for path in &observed.parameter_drift {
665 out.result_line(format!(
666 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
667 ));
668 }
669 for reason in &observed.record_drift {
670 out.result_line(format!("DRIFT record: {reason}"));
671 }
672 for path in &observed.drift_seeded {
673 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
674 }
675 for path in &observed.missing {
676 out.result_line(format!("MISSING {path}"));
677 }
678 for pin in &observed.stale {
679 out.result_line(format!(
680 "STALE {} {} landed, {} in this binary",
681 pin.tool, pin.landed, pin.available
682 ));
683 }
684 for (path, line, text) in &observed.sentinels {
685 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
686 }
687 for failure in &observed.invariants {
688 out.result_line(format!(
689 "INVARIANT {} ({}): {}",
690 failure.destination, failure.code, failure.reason
691 ));
692 }
693 for warning in &observed.warnings {
694 out.result_line(format!("WARNING ({}): {}", warning.code, warning.reason));
695 }
696 let mut next = Vec::new();
697 for failure in &observed.invariants {
698 next.push(format!("{}: {}", failure.destination, failure.remediation));
699 }
700 if !observed.incompatible.is_empty() {
704 next.push(format!(
705 "rk upgrade --code-scanning off --target {} drops the capability this target cannot run; name a provider its pair ships to keep one",
706 args.target
707 ));
708 }
709 if observed.record_drift.len() > observed.incompatible.len() {
710 next.push(format!(
711 "rk upgrade --target {} rewrites the receipt from its parameters",
712 args.target
713 ));
714 }
715 if observed
716 .pending
717 .as_deref()
718 .is_none_or(|paths| !paths.is_empty())
719 {
720 next.push(format!(
721 "rk upgrade --target {} takes this landing to {}",
722 args.target,
723 env!("CARGO_PKG_VERSION")
724 ));
725 }
726 next.push(format!(
727 "rk status --check --target {} exits 1 on a violation",
728 args.target
729 ));
730 out.next(&next);
731}
732
733#[cfg(test)]
734mod tests {
735 use super::{Drift, InvariantFailure, Report, StalePin};
736
737 #[test]
740 fn the_status_report_schema_snapshot_holds() {
741 let landed = Report {
742 schema: "rk.status/11",
743 landed: true,
744 config: super::ConfigState {
745 state: "pending",
746 pending: vec!["landing.style".into()],
747 },
748 tech: Some("rust".into()),
749 forge: Some("github".into()),
750 workflow: Some("worktree"),
751 style: Some("trunk"),
752 nix: Some(true),
753 scorecard: Some(false),
754 code_scanning: Some("semgrep"),
755 rk_version: Some("0.1.0".into()),
756 binary_version: Some("0.2.0"),
757 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
758 drift: Some(Drift {
759 rendered: 0,
760 seeded: 1,
761 }),
762 missing: Some(vec![]),
763 stale_pins: Some(vec![StalePin {
764 tool: "release-plz".into(),
765 landed: "0.3.160".into(),
766 available: "0.3.170".into(),
767 }]),
768 sentinels: Some(1),
769 record_drift: Some(0),
770 invariant_failures: Some(vec![InvariantFailure {
771 code: "attestations-disabled",
772 destination: "dist-workspace.toml".into(),
773 reason: "github-attestations is not effectively true".into(),
774 remediation: "set github-attestations = true in [dist]",
775 }]),
776 warnings: Some(vec![super::Warning {
777 code: super::CODE_SCANNING_LICENCE,
778 reason: "the target's license, LicenseRef-proprietary, is not one this release recognizes as OSI-approved".into(),
779 }]),
780 pending: Some(2),
781 violations: None,
782 };
783 assert_eq!(
784 serde_json::to_string(&landed).expect("a report serializes"),
785 r#"{"schema":"rk.status/11","landed":true,"config":{"state":"pending","pending":["landing.style"]},"tech":"rust","forge":"github","workflow":"worktree","style":"trunk","nix":true,"scorecard":false,"code_scanning":"semgrep","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]"}],"warnings":[{"code":"code-scanning-licence","reason":"the target's license, LicenseRef-proprietary, is not one this release recognizes as OSI-approved"}],"pending":2}"#
786 );
787 let absent = Report {
788 landed: false,
789 config: super::ConfigState {
790 state: "absent",
791 pending: vec![],
792 },
793 tech: None,
794 forge: None,
795 workflow: None,
796 style: None,
797 nix: None,
798 scorecard: None,
799 code_scanning: None,
800 rk_version: None,
801 binary_version: None,
802 alignment: None,
803 drift: None,
804 missing: None,
805 stale_pins: None,
806 sentinels: None,
807 record_drift: None,
808 invariant_failures: None,
809 warnings: None,
810 pending: None,
811 violations: None,
812 ..landed
813 };
814 assert_eq!(
815 serde_json::to_string(&absent).expect("a report serializes"),
816 r#"{"schema":"rk.status/11","landed":false,"config":{"state":"absent","pending":[]}}"#,
817 "an absent landing reports one field a caller can branch on"
818 );
819 }
820}