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::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseMode};
27use crate::projection::{Candidate, Projection, ProjectionInput, TargetEvidence};
28use crate::stage::CapabilityNote;
29use crate::{embedded, registry};
30
31#[derive(Debug, Serialize)]
33struct Drift {
34 rendered: usize,
36 seeded: usize,
38}
39
40#[derive(Debug, Serialize)]
42struct StalePin {
43 tool: String,
45 landed: String,
47 available: String,
49}
50
51#[derive(Debug, Serialize)]
57struct Warning {
58 code: &'static str,
60 reason: String,
62}
63
64const CODE_SCANNING_LICENCE: &str = "code-scanning-licence";
66
67#[derive(Debug, serde::Serialize)]
69struct ConfigState {
70 state: &'static str,
71 pending: Vec<String>,
72}
73
74fn config_state(config: Option<&crate::config::Config>, record: Option<&Manifest>) -> ConfigState {
75 let pending = config
76 .zip(record)
77 .map_or_else(Vec::new, |(config, record)| {
78 crate::config::pending(config, record)
79 });
80 ConfigState {
81 state: if config.is_none() {
82 "absent"
83 } else if pending.is_empty() {
84 "aligned"
85 } else {
86 "pending"
87 },
88 pending,
89 }
90}
91
92#[derive(Debug, Serialize)]
94struct Report {
95 schema: &'static str,
97 landed: bool,
99 config: ConfigState,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 profile: Option<ProfileSnapshot>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 git: Option<GitWorkflow>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 capabilities: Option<CapabilityRequests>,
109 #[serde(skip_serializing_if = "Option::is_none")]
112 selection: Option<Vec<CapabilityNote>>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 rk_version: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 binary_version: Option<&'static str>,
117 #[serde(skip_serializing_if = "Option::is_none")]
118 alignment: Option<Alignment>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 drift: Option<Drift>,
121 #[serde(skip_serializing_if = "Option::is_none")]
123 missing: Option<Vec<String>>,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 stale_pins: Option<Vec<StalePin>>,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 sentinels: Option<usize>,
129 #[serde(skip_serializing_if = "Option::is_none")]
133 record_drift: Option<usize>,
134 #[serde(skip_serializing_if = "Option::is_none")]
137 invariant_failures: Option<Vec<InvariantFailure>>,
138 #[serde(skip_serializing_if = "Option::is_none")]
142 warnings: Option<Vec<Warning>>,
143 #[serde(skip_serializing_if = "Option::is_none")]
149 pending: Option<usize>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 violations: Option<Vec<String>>,
153}
154
155fn report_absent(
156 out: Output,
157 args: &StatusArgs,
158 config: Option<&crate::config::Config>,
159) -> Result<(), RkError> {
160 out.result_line(format!("config: {}", config_state(config, None).state));
161 out.result_line(format!("no landing at {}", args.target));
162 out.next(&[
163 format!(
164 "rk profile --target {} reports what a landing would select",
165 args.target
166 ),
167 format!("rk init --target {} lands the workflow", args.target),
168 format!(
169 "rk adopt --target {} records a landing made before the receipt existed",
170 args.target
171 ),
172 format!(
173 "rk stage --target {} stages this binary's candidate; the rk-setup skill carries the best-effort migration",
174 args.target
175 ),
176 ]);
177 out.emit(&Report {
178 schema: "rk.status/12",
179 landed: false,
180 config: config_state(config, None),
181 profile: None,
182 git: None,
183 capabilities: None,
184 selection: None,
185 rk_version: None,
186 binary_version: None,
187 alignment: None,
188 drift: None,
189 missing: None,
190 stale_pins: None,
191 sentinels: None,
192 record_drift: None,
193 invariant_failures: None,
194 warnings: None,
195 pending: None,
196 violations: args.check.then(|| vec!["no landing".to_owned()]),
197 })?;
198 if args.check {
199 return Err(RkError::check_failed(
200 Diagnostic::new(
201 Reason::StateDrift,
202 format!("no landing at {}, and --check requires one", args.target),
203 )
204 .expected("a target carrying .release-kit/manifest.json")
205 .action("rk init lands the workflow; rk adopt records an existing landing; rk stage and the rk-setup skill carry the migration"),
206 ));
207 }
208 Ok(())
209}
210
211struct Observed {
213 drift_rendered: Vec<String>,
214 drift_seeded: Vec<String>,
215 parameter_drift: Vec<String>,
218 record_drift: Vec<String>,
223 missing: Vec<String>,
224 stale: Vec<StalePin>,
225 sentinels: Vec<(String, usize, String)>,
226 invariants: Vec<InvariantFailure>,
227 warnings: Vec<Warning>,
229 incompatible: Vec<String>,
235 pending: Option<Vec<String>>,
239 selection: Option<Vec<CapabilityNote>>,
241}
242
243pub fn run(args: &StatusArgs) -> Result<(), RkError> {
252 let out = Output::new(args.json);
253 if !args.target.is_dir() {
254 return Err(RkError::missing(
255 Diagnostic::new(
256 Reason::TargetNotFound,
257 format!("target {} is not a directory", args.target),
258 )
259 .expected("an existing repository to report on"),
260 ));
261 }
262 let config = crate::config::load(args.target.as_std_path())?;
263 let Some(manifest) = manifest::load(&args.target)? else {
264 return report_absent(out, args, config.as_ref());
265 };
266
267 let config = config_state(config.as_ref(), Some(&manifest));
268 out.result_line(format!("config: {}", config.state));
269 for key in &config.pending {
270 out.result_line(format!(
271 "config pending: {key}; rk upgrade --apply takes it up"
272 ));
273 }
274 let observed = observe(args, &manifest)?;
275 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
276 render_human(out, args, &manifest, alignment, &observed);
277
278 let violations = violations_of(&observed);
279 out.emit(&Report {
280 schema: "rk.status/12",
281 landed: true,
282 config,
283 profile: Some(manifest.profile.clone()),
284 git: Some(manifest.git.clone()),
285 capabilities: Some(manifest.capabilities.clone()),
286 selection: observed.selection.clone(),
287 rk_version: Some(manifest.rk_version),
288 binary_version: Some(env!("CARGO_PKG_VERSION")),
289 alignment: Some(alignment),
290 drift: Some(Drift {
291 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
292 seeded: observed.drift_seeded.len(),
293 }),
294 record_drift: Some(observed.record_drift.len()),
295 missing: Some(observed.missing.clone()),
296 stale_pins: Some(observed.stale),
297 sentinels: Some(observed.sentinels.len()),
298 invariant_failures: Some(observed.invariants),
299 warnings: Some(observed.warnings),
300 pending: observed.pending.as_ref().map(Vec::len),
301 violations: args.check.then(|| violations.clone()),
302 })?;
303
304 if args.check && !violations.is_empty() {
305 return Err(RkError::check_failed(
306 Diagnostic::new(
307 Reason::StateDrift,
308 format!(
309 "the landing is not clean: {} violation{}",
310 violations.len(),
311 if violations.len() == 1 { "" } else { "s" }
312 ),
313 )
314 .expected(
315 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
316 ),
317 ));
318 }
319 Ok(())
320}
321
322fn violations_of(observed: &Observed) -> Vec<String> {
326 observed
327 .drift_rendered
328 .iter()
329 .map(|path| format!("rendered drift: {path}"))
330 .chain(
331 observed
332 .parameter_drift
333 .iter()
334 .map(|path| format!("parameter drift: {path}")),
335 )
336 .chain(
337 observed
338 .record_drift
339 .iter()
340 .map(|reason| format!("record drift: {reason}")),
341 )
342 .chain(
343 observed
344 .missing
345 .iter()
346 .map(|path| format!("missing: {path}")),
347 )
348 .chain(
349 observed
350 .sentinels
351 .iter()
352 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
353 )
354 .chain(
355 observed
356 .invariants
357 .iter()
358 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
359 )
360 .collect()
361}
362
363#[allow(
366 clippy::too_many_lines,
367 reason = "one pass over the record and the disk answers every comparison the report states"
368)]
369fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
370 let mut observed = Observed {
371 drift_rendered: Vec::new(),
372 drift_seeded: Vec::new(),
373 parameter_drift: Vec::new(),
374 record_drift: Vec::new(),
375 missing: Vec::new(),
376 stale: Vec::new(),
377 sentinels: Vec::new(),
378 invariants: Vec::new(),
379 warnings: Vec::new(),
380 incompatible: Vec::new(),
381 pending: None,
382 selection: None,
383 };
384 let aligned =
390 manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION")) == Alignment::Aligned;
391 let projected = match project(args, manifest) {
392 Ok(projection) => Some(projection),
393 Err(err) if aligned => return Err(err),
394 Err(_) => None,
395 };
396 for file in &manifest.files {
397 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
398 observed.missing.push(file.destination.clone());
399 continue;
400 };
401 if Digest::of(&bytes) != file.sha256 {
402 match file.kind {
403 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
404 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
405 Kind::State => {}
406 }
407 }
408 observed.invariants.extend(invariants::failures(
409 manifest.profile.release.driver.as_deref().unwrap_or(""),
410 manifest.profile.forge.as_deref().unwrap_or(""),
411 &file.destination,
412 &bytes,
413 ));
414 let text = String::from_utf8_lossy(&bytes);
415 for (idx, line) in text.lines().enumerate() {
416 if line.contains(embedded::SENTINEL) {
417 observed.sentinels.push((
418 file.destination.clone(),
419 idx + 1,
420 line.trim().to_owned(),
421 ));
422 }
423 }
424 let defective = projected.as_ref().is_some_and(|projection| {
429 projection
430 .collisions
431 .iter()
432 .any(|collision| collision.destination == file.destination)
433 });
434 if defective && !observed.drift_rendered.contains(&file.destination) {
435 observed.drift_rendered.push(file.destination.clone());
436 }
437 }
438 observed.invariants.extend(invariants::target_failures(
443 manifest.profile.release.driver.as_deref().unwrap_or(""),
444 manifest.profile.forge.as_deref().unwrap_or(""),
445 &args.target,
446 ));
447 observed.selection = projected.as_ref().map(|projection| {
448 projection
449 .capabilities
450 .iter()
451 .map(|selection| CapabilityNote::of(selection, projection))
452 .collect()
453 });
454 if let Some(reason) = projected
455 .as_ref()
456 .and_then(|projection| projection.licence_refusal.clone())
457 {
458 observed.warnings.push(Warning {
459 code: CODE_SCANNING_LICENCE,
460 reason,
461 });
462 }
463 if let Some(projection) = projected.as_ref() {
468 observed.incompatible.clone_from(&projection.record_defects);
469 observed
470 .record_drift
471 .extend(projection.record_defects.iter().cloned());
472 }
473 if aligned && let Some(projection) = projected.as_ref() {
474 observe_parameter_drift(manifest, projection, &mut observed);
475 observe_record_set(manifest, projection, &mut observed.record_drift);
476 }
477 observed.pending = projected
482 .as_ref()
483 .map(|projection| pending_of(manifest, &projection.candidates));
484 for (tool, landed) in &manifest.pins {
488 if let Some(available) = registry::version_of(tool)
489 && manifest::version_is_newer(&available, landed)
490 {
491 observed.stale.push(StalePin {
492 tool: tool.clone(),
493 landed: landed.clone(),
494 available,
495 });
496 }
497 }
498 Ok(observed)
499}
500
501fn observe_parameter_drift(manifest: &Manifest, projection: &Projection, observed: &mut Observed) {
513 for candidate in &projection.candidates {
514 if candidate.kind != Kind::Rendered {
515 continue;
516 }
517 let Some(record) = manifest.file(&candidate.destination) else {
518 continue;
519 };
520 if observed.drift_rendered.contains(&candidate.destination)
521 || observed.missing.contains(&candidate.destination)
522 {
523 continue;
524 }
525 if Digest::of(recorded_form(candidate)) != record.sha256 {
526 observed
527 .parameter_drift
528 .push(format!("{} (parameters)", candidate.destination));
529 }
530 }
531}
532
533fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Projection, RkError> {
537 let evidence = TargetEvidence::gather(&args.target, Some(manifest))?;
538 Projection::compute(&ProjectionInput {
539 params: landing::Params::from_record(manifest),
540 evidence,
541 })
542}
543
544fn recorded_form(candidate: &Candidate) -> &[u8] {
547 candidate.region.as_deref().unwrap_or(&candidate.bytes)
548}
549
550fn pending_of(manifest: &Manifest, projected: &[Candidate]) -> Vec<String> {
559 let mut pending = Vec::new();
560 for entry in projected {
561 let changed = manifest.file(&entry.destination).is_none_or(|record| {
562 record.kind != entry.kind
563 || (entry.kind == Kind::Rendered
564 && record.sha256 != Digest::of(recorded_form(entry)))
565 });
566 if changed {
567 pending.push(entry.destination.clone());
568 }
569 }
570 for file in &manifest.files {
571 if !projected
572 .iter()
573 .any(|entry| entry.destination == file.destination)
574 {
575 pending.push(file.destination.clone());
576 }
577 }
578 pending.sort();
579 pending.dedup();
580 pending
581}
582
583fn observe_record_set(
595 manifest: &Manifest,
596 projection: &Projection,
597 record_drift: &mut Vec<String>,
598) {
599 for entry in &projection.candidates {
600 if manifest.file(&entry.destination).is_none() {
601 record_drift.push(format!(
602 "the recorded parameters project {}, which the receipt does not name",
603 entry.destination
604 ));
605 }
606 }
607 for file in &manifest.files {
608 let produced = projection
609 .candidates
610 .iter()
611 .any(|entry| entry.destination == file.destination)
612 || projection
613 .omissions
614 .iter()
615 .any(|omission| omission.destination == file.destination);
616 if !produced {
617 record_drift.push(format!(
618 "the receipt names {}, which the recorded parameters do not project",
619 file.destination
620 ));
621 }
622 }
623}
624
625#[allow(
627 clippy::too_many_lines,
628 reason = "one pass prints every reportable condition in the order the report states them"
629)]
630fn render_human(
631 out: Output,
632 args: &StatusArgs,
633 manifest: &Manifest,
634 alignment: Alignment,
635 observed: &Observed,
636) {
637 out.result_line(format!(
638 "release-kit {} at {}: {}",
639 manifest.rk_version,
640 args.target,
641 crate::commands::profile::describe(
642 &manifest.profile,
643 &manifest.git,
644 &manifest.capabilities,
645 &manifest.parameters.repo
646 )
647 ));
648 for note in observed.selection.iter().flatten() {
652 if note.status != "selected" {
653 out.result_line(format!(
654 "capability {}: {}{}",
655 note.id,
656 note.status,
657 note.reason
658 .as_deref()
659 .map_or_else(String::new, |reason| format!(" ({reason})"))
660 ));
661 }
662 }
663 if alignment == Alignment::TargetNewer {
664 out.result_line(format!(
665 "binary {} is older than this landing; install the matching rk",
666 env!("CARGO_PKG_VERSION")
667 ));
668 }
669 match observed.pending.as_deref() {
670 None => out.result_line(format!(
671 "this binary carries no projection for the recorded {} release on {}, so what an upgrade would change is unknown",
672 manifest
673 .profile
674 .release
675 .driver
676 .as_deref()
677 .unwrap_or("release-less"),
678 manifest.profile.forge.as_deref().unwrap_or("no forge")
679 )),
680 Some(paths) => {
681 for path in paths {
682 out.result_line(format!("PENDING {path} (this binary would change it)"));
683 }
684 }
685 }
686 for path in &observed.drift_rendered {
687 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
688 }
689 for path in &observed.parameter_drift {
690 out.result_line(format!(
691 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
692 ));
693 }
694 for reason in &observed.record_drift {
695 out.result_line(format!("DRIFT record: {reason}"));
696 }
697 for path in &observed.drift_seeded {
698 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
699 }
700 for path in &observed.missing {
701 out.result_line(format!("MISSING {path}"));
702 }
703 for pin in &observed.stale {
704 out.result_line(format!(
705 "STALE {} {} landed, {} in this binary",
706 pin.tool, pin.landed, pin.available
707 ));
708 }
709 for (path, line, text) in &observed.sentinels {
710 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
711 }
712 for failure in &observed.invariants {
713 out.result_line(format!(
714 "INVARIANT {} ({}): {}",
715 failure.destination, failure.code, failure.reason
716 ));
717 }
718 for warning in &observed.warnings {
719 out.result_line(format!("WARNING ({}): {}", warning.code, warning.reason));
720 }
721 let mut next = Vec::new();
722 for failure in &observed.invariants {
723 next.push(format!("{}: {}", failure.destination, failure.remediation));
724 }
725 if !observed.incompatible.is_empty() {
729 next.push(format!(
730 "rk upgrade --release-mode none --target {} retires the release intent this target cannot run; name a driver and forge this release carries to keep one",
731 args.target
732 ));
733 }
734 if observed.record_drift.len() > observed.incompatible.len() {
735 next.push(format!(
736 "rk upgrade --target {} rewrites the receipt from its parameters",
737 args.target
738 ));
739 }
740 if observed
741 .pending
742 .as_deref()
743 .is_none_or(|paths| !paths.is_empty())
744 {
745 next.push(format!(
746 "rk upgrade --target {} takes this landing to {}",
747 args.target,
748 env!("CARGO_PKG_VERSION")
749 ));
750 }
751 next.push(format!(
752 "rk status --check --target {} exits 1 on a violation",
753 args.target
754 ));
755 if manifest.profile.release.mode != ReleaseMode::Automatic {
756 next.retain(|line| !line.contains("rk method operate"));
757 }
758 out.next(&next);
759}
760
761#[cfg(test)]
762mod tests {
763 use super::{Drift, InvariantFailure, Report, StalePin};
764 use crate::landing::CheckoutMode;
765 use crate::profile::{
766 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
767 };
768
769 #[test]
772 fn the_status_report_schema_snapshot_holds() {
773 let landed = Report {
774 schema: "rk.status/12",
775 landed: true,
776 config: super::ConfigState {
777 state: "pending",
778 pending: vec!["profile.release.style".into()],
779 },
780 profile: Some(ProfileSnapshot {
781 technologies: vec!["rust".into()],
782 forge: Some("github".into()),
783 release: ReleaseIntent {
784 mode: ReleaseMode::Automatic,
785 driver: Some("rust".into()),
786 style: Some(crate::landing::Style::Trunk),
787 line_prefix: Some("release/".into()),
788 },
789 }),
790 git: Some(GitWorkflow {
791 trunk: "master".into(),
792 checkout_mode: CheckoutMode::LinkedWorktree,
793 }),
794 capabilities: Some(CapabilityRequests {
795 nix_packaging: true,
796 reporting_policy: true,
797 scorecard: false,
798 code_scanning: Some(crate::landing::Provider::Semgrep),
799 }),
800 selection: Some(vec![]),
801 rk_version: Some("0.1.0".into()),
802 binary_version: Some("0.2.0"),
803 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
804 drift: Some(Drift {
805 rendered: 0,
806 seeded: 1,
807 }),
808 missing: Some(vec![]),
809 stale_pins: Some(vec![StalePin {
810 tool: "release-plz".into(),
811 landed: "0.3.160".into(),
812 available: "0.3.170".into(),
813 }]),
814 sentinels: Some(1),
815 record_drift: Some(0),
816 invariant_failures: Some(vec![InvariantFailure {
817 code: "attestations-disabled",
818 destination: "dist-workspace.toml".into(),
819 reason: "github-attestations is not effectively true".into(),
820 remediation: "set github-attestations = true in [dist]",
821 }]),
822 warnings: Some(vec![super::Warning {
823 code: super::CODE_SCANNING_LICENCE,
824 reason: "the target's license, LicenseRef-proprietary, is not one this release recognizes as OSI-approved".into(),
825 }]),
826 pending: Some(2),
827 violations: None,
828 };
829 assert_eq!(
830 serde_json::to_string(&landed).expect("a report serializes"),
831 r#"{"schema":"rk.status/12","landed":true,"config":{"state":"pending","pending":["profile.release.style"]},"profile":{"technologies":["rust"],"forge":"github","release":{"mode":"automatic","driver":"rust","style":"trunk","line_prefix":"release/"}},"git":{"trunk":"master","checkout_mode":"linked-worktree"},"capabilities":{"nix_packaging":true,"reporting_policy":true,"scorecard":false,"code_scanning":"semgrep"},"selection":[],"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}"#
832 );
833 let absent = Report {
834 landed: false,
835 config: super::ConfigState {
836 state: "absent",
837 pending: vec![],
838 },
839 profile: None,
840 git: None,
841 capabilities: None,
842 selection: None,
843 rk_version: None,
844 binary_version: None,
845 alignment: None,
846 drift: None,
847 missing: None,
848 stale_pins: None,
849 sentinels: None,
850 record_drift: None,
851 invariant_failures: None,
852 warnings: None,
853 pending: None,
854 violations: None,
855 ..landed
856 };
857 assert_eq!(
858 serde_json::to_string(&absent).expect("a report serializes"),
859 r#"{"schema":"rk.status/12","landed":false,"config":{"state":"absent","pending":[]}}"#,
860 "an absent landing reports one field a caller can branch on"
861 );
862 }
863}