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::RegistryAnswer;
27use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseMode};
28use crate::projection::{Candidate, Projection, ProjectionInput, TargetEvidence};
29use crate::stage::CapabilityNote;
30use crate::{assess, embedded, registry};
31
32#[derive(Debug, Serialize)]
34struct Drift {
35 rendered: usize,
37 seeded: usize,
39}
40
41#[derive(Debug, Serialize)]
43struct StalePin {
44 tool: String,
46 landed: String,
48 available: String,
50}
51
52#[derive(Debug, Serialize)]
58struct Warning {
59 code: &'static str,
61 reason: String,
63}
64
65#[derive(Debug, Clone, Serialize)]
70struct SetupProof {
71 state: &'static str,
73 #[serde(skip_serializing_if = "Vec::is_empty")]
75 changed: Vec<String>,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 reason: Option<&'static str>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 detail: Option<String>,
82}
83
84const SETUP_PROOF_REMEDIATION: &str = "run rk setup check --target ., then rk setup checkpoint --target ., and commit .release-kit/setup-proof.json; a step this forge cannot run goes in setup.excluded_steps with its reason, which keeps the proof complete";
87
88impl SetupProof {
89 fn of(standing: &crate::setup::proof::Standing) -> Self {
90 use crate::setup::proof::Standing;
91 let mut proof = Self {
92 state: standing.word(),
93 changed: Vec::new(),
94 reason: None,
95 detail: None,
96 };
97 match standing {
98 Standing::Stale { differences, .. } => proof.changed.clone_from(differences),
99 Standing::Invalid { reason, detail } => {
100 proof.reason = Some(reason);
101 proof.detail = Some(detail.clone());
102 }
103 Standing::Absent | Standing::Compatible(_) => {}
104 }
105 proof
106 }
107
108 fn failure(&self) -> Option<InvariantFailure> {
111 let (code, reason) = match self.state {
112 "absent" => (
113 "setup-proof-absent",
114 "no .release-kit/setup-proof.json is committed, so nothing proves the forge, the registry, and the bot meet the setup contract".to_owned(),
115 ),
116 "stale" => (
117 "setup-proof-stale",
118 format!(
119 "the committed setup proof was written for another setup contract; changed: {}",
120 self.changed.join(", ")
121 ),
122 ),
123 "invalid" => (
124 "setup-proof-invalid",
125 format!(
126 "the committed setup proof cannot be read: {}: {}",
127 self.reason.unwrap_or("invalid"),
128 self.detail.as_deref().unwrap_or("")
129 ),
130 ),
131 _ => return None,
132 };
133 Some(InvariantFailure {
134 code,
135 destination: crate::setup::proof::PROOF_PATH.to_owned(),
136 reason,
137 remediation: SETUP_PROOF_REMEDIATION,
138 })
139 }
140}
141
142fn setup_proof_of(
146 target: &camino::Utf8PathBuf,
147 manifest: &Manifest,
148) -> Result<Option<SetupProof>, RkError> {
149 if manifest.profile.release.mode != ReleaseMode::Automatic || manifest.profile.forge.is_none() {
150 return Ok(None);
151 }
152 Ok(Some(SetupProof::of(&crate::setup::proof::offline(target)?)))
153}
154
155const CODE_SCANNING_LICENCE: &str = "code-scanning-licence";
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
163#[serde(rename_all = "kebab-case")]
164pub enum Classification {
165 Setup,
167 Migration,
169 NeedsDecision,
171 Adopt,
173 Upgrade,
175 Drift,
177 Current,
179}
180
181impl Classification {
182 #[must_use]
184 pub const fn as_str(self) -> &'static str {
185 match self {
186 Self::Setup => "setup",
187 Self::Migration => "migration",
188 Self::NeedsDecision => "needs-decision",
189 Self::Adopt => "adopt",
190 Self::Upgrade => "upgrade",
191 Self::Drift => "drift",
192 Self::Current => "current",
193 }
194 }
195}
196
197const fn unrecorded(class: assess::Unrecorded) -> Classification {
200 match class {
201 assess::Unrecorded::Adopt => Classification::Adopt,
202 assess::Unrecorded::Assessed(assess::Classification::Greenfield) => Classification::Setup,
203 assess::Unrecorded::Assessed(assess::Classification::Brownfield) => {
204 Classification::Migration
205 }
206 assess::Unrecorded::Assessed(assess::Classification::NeedsDecision) => {
207 Classification::NeedsDecision
208 }
209 }
210}
211
212const fn recorded(alignment: Alignment, clean: bool) -> Option<Classification> {
216 match alignment {
217 Alignment::TargetNewer => None,
218 Alignment::BinaryNewer => Some(Classification::Upgrade),
219 Alignment::Aligned if clean => Some(Classification::Current),
220 Alignment::Aligned => Some(Classification::Drift),
221 }
222}
223
224#[derive(Debug, Clone, Serialize)]
226struct SentinelAt {
227 file: String,
229 line: usize,
231 text: String,
233}
234
235#[derive(Debug, Serialize)]
237struct LegacyPath {
238 path: String,
240 kind: &'static str,
242 active: bool,
244}
245
246fn legacy_state() -> Vec<LegacyPath> {
255 let Some(root) = crate::applog::state_root() else {
256 return Vec::new();
257 };
258 let mut found = Vec::new();
259 if let Ok(entries) = std::fs::read_dir(root.join(LEGACY_PLANS)) {
260 let mut plans: Vec<std::path::PathBuf> = entries
261 .flatten()
262 .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
263 .map(|entry| entry.path())
264 .filter(|path| path.is_dir())
265 .collect();
266 plans.sort();
267 for plan in plans {
268 let active = std::fs::read(plan.join("plan.json"))
269 .ok()
270 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
271 .is_none_or(|document| document["readiness"].as_str() == Some("ready"));
272 found.push(LegacyPath {
273 path: plan.display().to_string(),
274 kind: "plan",
275 active,
276 });
277 }
278 }
279 let cache = root.join(LEGACY_RELEASE_CACHE);
280 if cache.is_dir() {
281 found.push(LegacyPath {
282 path: cache.display().to_string(),
283 kind: "release-cache",
284 active: false,
285 });
286 }
287 found
288}
289
290const LEGACY_PLANS: &str = "plans";
292
293const LEGACY_RELEASE_CACHE: &str = "release";
295
296const SCHEMA: &str = "rk.status/14";
298
299#[derive(Debug, serde::Serialize)]
301struct ConfigState {
302 state: &'static str,
303 pending: Vec<String>,
304}
305
306fn config_state(config: Option<&crate::config::Config>, record: Option<&Manifest>) -> ConfigState {
307 let pending = config
308 .zip(record)
309 .map_or_else(Vec::new, |(config, record)| {
310 crate::config::pending(config, record)
311 });
312 ConfigState {
313 state: if config.is_none() {
314 "absent"
315 } else if pending.is_empty() {
316 "aligned"
317 } else {
318 "pending"
319 },
320 pending,
321 }
322}
323
324#[derive(Debug, Serialize)]
326struct Report {
327 schema: &'static str,
329 landed: bool,
331 classification: Option<Classification>,
334 git_work_tree: bool,
336 config: ConfigState,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 profile: Option<ProfileSnapshot>,
340 #[serde(skip_serializing_if = "Option::is_none")]
342 git: Option<GitWorkflow>,
343 #[serde(skip_serializing_if = "Option::is_none")]
345 capabilities: Option<CapabilityRequests>,
346 #[serde(skip_serializing_if = "Option::is_none")]
349 selection: Option<Vec<CapabilityNote>>,
350 #[serde(skip_serializing_if = "Option::is_none")]
351 rk_version: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
353 binary_version: Option<&'static str>,
354 #[serde(skip_serializing_if = "Option::is_none")]
355 alignment: Option<Alignment>,
356 #[serde(skip_serializing_if = "Option::is_none")]
357 drift: Option<Drift>,
358 #[serde(skip_serializing_if = "Option::is_none")]
360 missing: Option<Vec<String>>,
361 #[serde(skip_serializing_if = "Option::is_none")]
362 stale_pins: Option<Vec<StalePin>>,
363 #[serde(skip_serializing_if = "Option::is_none")]
365 sentinels: Option<usize>,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 sentinel_locations: Option<Vec<SentinelAt>>,
369 #[serde(skip_serializing_if = "Option::is_none")]
373 record_drift: Option<usize>,
374 #[serde(skip_serializing_if = "Option::is_none")]
377 invariant_failures: Option<Vec<InvariantFailure>>,
378 #[serde(skip_serializing_if = "Option::is_none")]
382 warnings: Option<Vec<Warning>>,
383 #[serde(skip_serializing_if = "Option::is_none")]
386 setup_proof: Option<SetupProof>,
387 #[serde(skip_serializing_if = "Option::is_none")]
393 pending: Option<usize>,
394 #[serde(skip_serializing_if = "Option::is_none")]
396 violations: Option<Vec<String>>,
397 legacy_state: Vec<LegacyPath>,
399 #[serde(skip_serializing_if = "Option::is_none")]
401 migration: Option<OpenMigration>,
402}
403
404#[derive(Debug, Serialize)]
406struct OpenMigration {
407 id: String,
409 #[serde(skip_serializing_if = "Option::is_none")]
411 workspace: Option<String>,
412 #[serde(skip_serializing_if = "Option::is_none")]
415 unfinished: Option<usize>,
416}
417
418fn open_migration(
423 target: &camino::Utf8Path,
424 config: Option<&crate::config::Config>,
425) -> Option<OpenMigration> {
426 let id = config.and_then(|config| config.migration.clone())?;
427 let workspace = crate::migrate::open_workspace(target).ok().flatten();
428 let unfinished = workspace
429 .as_ref()
430 .filter(|workspace| workspace.join(crate::migrate::REGISTRY_FILE).is_file())
431 .and_then(|workspace| crate::migrate::registry::load(workspace).ok())
432 .map(|registry| {
433 registry
434 .sources
435 .iter()
436 .filter(|source| !source.status.finished())
437 .count()
438 });
439 Some(OpenMigration {
440 id,
441 workspace: workspace.map(|workspace| workspace.to_string()),
442 unfinished,
443 })
444}
445
446fn render_migration(out: Output, migration: Option<&OpenMigration>) {
448 if let Some(migration) = migration {
449 out.result_line(format!(
450 "migration {} is open; rk migrate status reads it",
451 migration.id
452 ));
453 }
454}
455
456fn report_absent(
457 out: Output,
458 args: &StatusArgs,
459 config: Option<&crate::config::Config>,
460 git_work_tree: bool,
461) -> Result<(), RkError> {
462 let migration = open_migration(&args.target, config);
463 let classification = match assess::standing(&args.target, config)? {
464 assess::Standing::Migrating => Classification::Migration,
465 assess::Standing::Unrecorded(class) => unrecorded(class),
466 };
467 let legacy = legacy_state();
468 out.result_line(format!("config: {}", config_state(config, None).state));
469 out.result_line(format!("no landing at {}", args.target));
470 out.result_line(format!("classification: {}", classification.as_str()));
471 render_migration(out, migration.as_ref());
472 render_context(out, git_work_tree, &legacy);
473 out.next(&[
474 format!(
475 "rk profile --target {} reports what a landing would select",
476 args.target
477 ),
478 format!("rk init --target {} lands the workflow", args.target),
479 format!(
480 "rk adopt --target {} records a landing made before the receipt existed",
481 args.target
482 ),
483 format!(
484 "rk stage --target {} stages this binary's candidate; the rk-setup skill carries the best-effort migration",
485 args.target
486 ),
487 ]);
488 out.emit(&Report {
489 schema: SCHEMA,
490 landed: false,
491 classification: Some(classification),
492 git_work_tree,
493 config: config_state(config, None),
494 profile: None,
495 git: None,
496 capabilities: None,
497 selection: None,
498 rk_version: None,
499 binary_version: None,
500 alignment: None,
501 drift: None,
502 missing: None,
503 stale_pins: None,
504 sentinels: None,
505 sentinel_locations: None,
506 record_drift: None,
507 invariant_failures: None,
508 warnings: None,
509 setup_proof: None,
510 pending: None,
511 violations: args.check.then(|| vec!["no landing".to_owned()]),
512 legacy_state: legacy,
513 migration,
514 })?;
515 if args.check {
516 return Err(RkError::check_failed(
517 Diagnostic::new(
518 Reason::StateDrift,
519 format!("no landing at {}, and --check requires one", args.target),
520 )
521 .expected("a target carrying .release-kit/manifest.json")
522 .action("rk init lands the workflow; rk adopt records an existing landing; rk stage and the rk-setup skill carry the migration"),
523 ));
524 }
525 Ok(())
526}
527
528struct Observed {
530 drift_rendered: Vec<String>,
531 drift_seeded: Vec<String>,
532 parameter_drift: Vec<String>,
535 record_drift: Vec<String>,
540 missing: Vec<String>,
541 stale: Vec<StalePin>,
542 sentinels: Vec<(String, usize, String)>,
543 invariants: Vec<InvariantFailure>,
544 warnings: Vec<Warning>,
546 setup_proof: Option<SetupProof>,
547 incompatible: Vec<String>,
553 pending: Option<Vec<String>>,
557 selection: Option<Vec<CapabilityNote>>,
559}
560
561pub fn run(args: &StatusArgs) -> Result<(), RkError> {
570 let out = Output::new(args.json);
571 if !args.target.is_dir() {
572 return Err(RkError::missing(
573 Diagnostic::new(
574 Reason::TargetNotFound,
575 format!("target {} is not a directory", args.target),
576 )
577 .expected("an existing repository to report on"),
578 ));
579 }
580 let config = crate::config::load(args.target.as_std_path())?;
581 let git_work_tree = assess::git_work_tree(&args.target)?;
582 let Some(manifest) = manifest::load(&args.target)? else {
583 return report_absent(out, args, config.as_ref(), git_work_tree);
584 };
585
586 let migration = open_migration(&args.target, config.as_ref());
587 let stated = Stated::of(config.as_ref(), &manifest);
588 let config = config_state(config.as_ref(), Some(&manifest));
589 out.result_line(format!("config: {}", config.state));
590 for key in &config.pending {
591 out.result_line(format!(
592 "config pending: {key}; rk upgrade --apply takes it up"
593 ));
594 }
595 let mut observed = observe(args, &manifest, &stated)?;
596 observed.setup_proof = setup_proof_of(&args.target, &manifest)?;
597 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
598 render_human(out, args, &manifest, alignment, &observed);
599
600 let violations = violations_of(&observed);
601 let classification = if migration.is_some() && alignment != Alignment::TargetNewer {
605 Some(Classification::Migration)
606 } else {
607 recorded(alignment, violations.is_empty())
608 };
609 out.result_line(format!(
610 "classification: {}",
611 classification.map_or(
612 "none: the record is newer than this binary",
613 Classification::as_str
614 )
615 ));
616 render_migration(out, migration.as_ref());
617 let legacy = legacy_state();
618 render_context(out, git_work_tree, &legacy);
619 out.emit(&Report {
620 schema: SCHEMA,
621 landed: true,
622 classification,
623 git_work_tree,
624 config,
625 profile: Some(manifest.profile.clone()),
626 git: Some(manifest.git.clone()),
627 capabilities: Some(manifest.capabilities.clone()),
628 selection: observed.selection.clone(),
629 rk_version: Some(manifest.rk_version),
630 binary_version: Some(env!("CARGO_PKG_VERSION")),
631 alignment: Some(alignment),
632 drift: Some(Drift {
633 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
634 seeded: observed.drift_seeded.len(),
635 }),
636 record_drift: Some(observed.record_drift.len()),
637 missing: Some(observed.missing.clone()),
638 stale_pins: Some(observed.stale),
639 sentinels: Some(observed.sentinels.len()),
640 sentinel_locations: Some(
641 observed
642 .sentinels
643 .iter()
644 .map(|(file, line, text)| SentinelAt {
645 file: file.clone(),
646 line: *line,
647 text: text.clone(),
648 })
649 .collect(),
650 ),
651 invariant_failures: Some(observed.invariants),
652 warnings: Some(observed.warnings),
653 setup_proof: observed.setup_proof.clone(),
654 pending: observed.pending.as_ref().map(Vec::len),
655 violations: args.check.then(|| violations.clone()),
656 legacy_state: legacy,
657 migration,
658 })?;
659
660 if args.check && !violations.is_empty() {
661 return Err(RkError::check_failed(
662 Diagnostic::new(
663 Reason::StateDrift,
664 format!(
665 "the landing is not clean: {} violation{}",
666 violations.len(),
667 if violations.len() == 1 { "" } else { "s" }
668 ),
669 )
670 .expected(
671 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
672 ),
673 ));
674 }
675 Ok(())
676}
677
678fn render_context(out: Output, git_work_tree: bool, legacy: &[LegacyPath]) {
682 if !git_work_tree {
683 out.result_line("not a non-bare Git work tree: the method lands files a work tree commits");
684 }
685 for entry in legacy {
686 out.result_line(format!(
687 "LEGACY {} ({}, {})",
688 entry.path,
689 entry.kind,
690 if entry.active { "active" } else { "inactive" }
691 ));
692 }
693}
694
695fn violations_of(observed: &Observed) -> Vec<String> {
699 observed
700 .drift_rendered
701 .iter()
702 .map(|path| format!("rendered drift: {path}"))
703 .chain(
704 observed
705 .parameter_drift
706 .iter()
707 .map(|path| format!("parameter drift: {path}")),
708 )
709 .chain(
710 observed
711 .record_drift
712 .iter()
713 .map(|reason| format!("record drift: {reason}")),
714 )
715 .chain(
716 observed
717 .missing
718 .iter()
719 .map(|path| format!("missing: {path}")),
720 )
721 .chain(
722 observed
723 .sentinels
724 .iter()
725 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
726 )
727 .chain(
728 observed
729 .invariants
730 .iter()
731 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
732 )
733 .chain(
734 observed
735 .setup_proof
736 .as_ref()
737 .and_then(SetupProof::failure)
738 .map(|failure| format!("setup proof: {}", failure.code)),
739 )
740 .collect()
741}
742
743struct Stated {
746 registry: Option<RegistryAnswer>,
748 required_check: String,
750}
751
752impl Stated {
753 fn of(config: Option<&crate::config::Config>, manifest: &Manifest) -> Self {
754 Self {
755 registry: config
756 .and_then(|config| config.profile.release.registry)
757 .or(manifest.profile.release.registry),
758 required_check: config.map_or_else(
761 || manifest.parameters.required_check.clone(),
762 |config| config.setup.required_check.clone(),
763 ),
764 }
765 }
766}
767
768#[allow(
771 clippy::too_many_lines,
772 reason = "one pass over the record and the disk answers every comparison the report states"
773)]
774fn observe(args: &StatusArgs, manifest: &Manifest, stated: &Stated) -> Result<Observed, RkError> {
775 let mut observed = Observed {
776 drift_rendered: Vec::new(),
777 drift_seeded: Vec::new(),
778 parameter_drift: Vec::new(),
779 record_drift: Vec::new(),
780 missing: Vec::new(),
781 stale: Vec::new(),
782 sentinels: Vec::new(),
783 invariants: Vec::new(),
784 warnings: Vec::new(),
785 setup_proof: None,
786 incompatible: Vec::new(),
787 pending: None,
788 selection: None,
789 };
790 let aligned =
796 manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION")) == Alignment::Aligned;
797 let projected = match project(args, manifest) {
798 Ok(projection) => Some(projection),
799 Err(err) if aligned => return Err(err),
800 Err(_) => None,
801 };
802 for file in &manifest.files {
803 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
804 observed.missing.push(file.destination.clone());
805 continue;
806 };
807 if Digest::of(&bytes) != file.sha256 {
808 match file.kind {
809 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
810 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
811 Kind::State => {}
812 }
813 }
814 observed.invariants.extend(invariants::failures(
815 manifest.profile.release.driver.as_deref().unwrap_or(""),
816 manifest.profile.forge.as_deref().unwrap_or(""),
817 &file.destination,
818 &bytes,
819 stated.registry,
820 ));
821 let text = String::from_utf8_lossy(&bytes);
822 for (idx, line) in text.lines().enumerate() {
823 if line.contains(embedded::SENTINEL) {
824 observed.sentinels.push((
825 file.destination.clone(),
826 idx + 1,
827 line.trim().to_owned(),
828 ));
829 }
830 }
831 let defective = projected.as_ref().is_some_and(|projection| {
836 projection
837 .collisions
838 .iter()
839 .any(|collision| collision.destination == file.destination)
840 });
841 if defective && !observed.drift_rendered.contains(&file.destination) {
842 observed.drift_rendered.push(file.destination.clone());
843 }
844 }
845 observed.invariants.extend(invariants::target_failures(
850 manifest.profile.release.driver.as_deref().unwrap_or(""),
851 manifest.profile.forge.as_deref().unwrap_or(""),
852 &args.target,
853 ));
854 if manifest.profile.release.driver.as_deref() == Some("rust")
857 && manifest.profile.forge.as_deref() == Some("github")
858 {
859 let dev_run = manifest
860 .parameters
861 .dev_environment
862 .as_ref()
863 .map_or("", |answer| answer.run.as_str());
864 let gate = invariants::dist_targets_built(&args.target, &stated.required_check, dev_run);
865 observed.invariants.extend(gate.failures);
866 observed.warnings.extend(
867 gate.limitations
868 .into_iter()
869 .map(|(code, reason)| Warning { code, reason }),
870 );
871 }
872 observed.selection = projected.as_ref().map(|projection| {
873 projection
874 .capabilities
875 .iter()
876 .map(|selection| CapabilityNote::of(selection, projection))
877 .collect()
878 });
879 if let Some(reason) = projected
880 .as_ref()
881 .and_then(|projection| projection.licence_refusal.clone())
882 {
883 observed.warnings.push(Warning {
884 code: CODE_SCANNING_LICENCE,
885 reason,
886 });
887 }
888 if let Some(projection) = projected.as_ref() {
893 observed.incompatible.clone_from(&projection.record_defects);
894 observed
895 .record_drift
896 .extend(projection.record_defects.iter().cloned());
897 }
898 if aligned && let Some(projection) = projected.as_ref() {
899 observe_parameter_drift(manifest, projection, &mut observed);
900 observe_record_set(manifest, projection, &mut observed.record_drift);
901 }
902 observed.pending = projected
907 .as_ref()
908 .map(|projection| pending_of(manifest, &projection.candidates));
909 for (tool, landed) in &manifest.pins {
913 if let Some(available) = registry::version_of(tool)
914 && manifest::version_is_newer(&available, landed)
915 {
916 observed.stale.push(StalePin {
917 tool: tool.clone(),
918 landed: landed.clone(),
919 available,
920 });
921 }
922 }
923 Ok(observed)
924}
925
926fn observe_parameter_drift(manifest: &Manifest, projection: &Projection, observed: &mut Observed) {
938 for candidate in &projection.candidates {
939 if candidate.kind != Kind::Rendered {
940 continue;
941 }
942 let Some(record) = manifest.file(&candidate.destination) else {
943 continue;
944 };
945 if observed.drift_rendered.contains(&candidate.destination)
946 || observed.missing.contains(&candidate.destination)
947 {
948 continue;
949 }
950 if Digest::of(recorded_form(candidate)) != record.sha256 {
951 observed
952 .parameter_drift
953 .push(format!("{} (parameters)", candidate.destination));
954 }
955 }
956}
957
958fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Projection, RkError> {
962 let evidence = TargetEvidence::gather(&args.target, Some(manifest))?;
963 Projection::compute(&ProjectionInput {
964 params: landing::Params::from_record(manifest),
965 evidence,
966 })
967}
968
969fn recorded_form(candidate: &Candidate) -> &[u8] {
972 candidate.region.as_deref().unwrap_or(&candidate.bytes)
973}
974
975fn pending_of(manifest: &Manifest, projected: &[Candidate]) -> Vec<String> {
984 let mut pending = Vec::new();
985 for entry in projected {
986 let changed = manifest.file(&entry.destination).is_none_or(|record| {
987 record.kind != entry.kind
988 || (entry.kind == Kind::Rendered
989 && record.sha256 != Digest::of(recorded_form(entry)))
990 });
991 if changed {
992 pending.push(entry.destination.clone());
993 }
994 }
995 for file in &manifest.files {
996 if !projected
997 .iter()
998 .any(|entry| entry.destination == file.destination)
999 {
1000 pending.push(file.destination.clone());
1001 }
1002 }
1003 pending.sort();
1004 pending.dedup();
1005 pending
1006}
1007
1008fn observe_record_set(
1020 manifest: &Manifest,
1021 projection: &Projection,
1022 record_drift: &mut Vec<String>,
1023) {
1024 for entry in &projection.candidates {
1025 if manifest.file(&entry.destination).is_none() {
1026 record_drift.push(format!(
1027 "the recorded parameters project {}, which the receipt does not name",
1028 entry.destination
1029 ));
1030 }
1031 }
1032 for file in &manifest.files {
1033 let produced = projection
1034 .candidates
1035 .iter()
1036 .any(|entry| entry.destination == file.destination)
1037 || projection
1038 .omissions
1039 .iter()
1040 .any(|omission| omission.destination == file.destination);
1041 if !produced {
1042 record_drift.push(format!(
1043 "the receipt names {}, which the recorded parameters do not project",
1044 file.destination
1045 ));
1046 }
1047 }
1048}
1049
1050#[allow(
1052 clippy::too_many_lines,
1053 reason = "one pass prints every reportable condition in the order the report states them"
1054)]
1055fn render_human(
1056 out: Output,
1057 args: &StatusArgs,
1058 manifest: &Manifest,
1059 alignment: Alignment,
1060 observed: &Observed,
1061) {
1062 out.result_line(format!(
1063 "release-kit {} at {}: {}",
1064 manifest.rk_version,
1065 args.target,
1066 crate::commands::profile::describe(
1067 &manifest.profile,
1068 &manifest.git,
1069 &manifest.capabilities,
1070 &manifest.parameters.repo
1071 )
1072 ));
1073 for note in observed.selection.iter().flatten() {
1077 if note.status != "selected" {
1078 out.result_line(format!(
1079 "capability {}: {}{}",
1080 note.id,
1081 note.status,
1082 note.reason
1083 .as_deref()
1084 .map_or_else(String::new, |reason| format!(" ({reason})"))
1085 ));
1086 }
1087 }
1088 if alignment == Alignment::TargetNewer {
1089 out.result_line(format!(
1090 "binary {} is older than this landing; install the matching rk",
1091 env!("CARGO_PKG_VERSION")
1092 ));
1093 }
1094 match observed.pending.as_deref() {
1095 None => out.result_line(format!(
1096 "this binary carries no projection for the recorded {} release on {}, so what an upgrade would change is unknown",
1097 manifest
1098 .profile
1099 .release
1100 .driver
1101 .as_deref()
1102 .unwrap_or("release-less"),
1103 manifest.profile.forge.as_deref().unwrap_or("no forge")
1104 )),
1105 Some(paths) => {
1106 for path in paths {
1107 out.result_line(format!("PENDING {path} (this binary would change it)"));
1108 }
1109 }
1110 }
1111 for path in &observed.drift_rendered {
1112 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
1113 }
1114 for path in &observed.parameter_drift {
1115 out.result_line(format!(
1116 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
1117 ));
1118 }
1119 for reason in &observed.record_drift {
1120 out.result_line(format!("DRIFT record: {reason}"));
1121 }
1122 for path in &observed.drift_seeded {
1123 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
1124 }
1125 for path in &observed.missing {
1126 out.result_line(format!("MISSING {path}"));
1127 }
1128 for pin in &observed.stale {
1129 out.result_line(format!(
1130 "STALE {} {} landed, {} in this binary",
1131 pin.tool, pin.landed, pin.available
1132 ));
1133 }
1134 for (path, line, text) in &observed.sentinels {
1135 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
1136 }
1137 for failure in &observed.invariants {
1138 out.result_line(format!(
1139 "INVARIANT {} ({}): {}",
1140 failure.destination, failure.code, failure.reason
1141 ));
1142 }
1143 for warning in &observed.warnings {
1144 out.result_line(format!("WARNING ({}): {}", warning.code, warning.reason));
1145 }
1146 let proof_failure = observed.setup_proof.as_ref().and_then(SetupProof::failure);
1147 if let Some(proof) = &observed.setup_proof {
1148 out.result_line(format!("setup proof: {}", proof.state));
1149 }
1150 if let Some(failure) = &proof_failure {
1151 out.result_line(format!(
1152 "SETUP-PROOF ({}): {}",
1153 failure.code, failure.reason
1154 ));
1155 }
1156 let mut next = Vec::new();
1157 for failure in observed.invariants.iter().chain(&proof_failure) {
1158 next.push(format!("{}: {}", failure.destination, failure.remediation));
1159 }
1160 if !observed.incompatible.is_empty() {
1164 next.push(format!(
1165 "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",
1166 args.target
1167 ));
1168 }
1169 if observed.record_drift.len() > observed.incompatible.len() {
1170 next.push(format!(
1171 "rk upgrade --target {} rewrites the receipt from its parameters",
1172 args.target
1173 ));
1174 }
1175 if observed
1176 .pending
1177 .as_deref()
1178 .is_none_or(|paths| !paths.is_empty())
1179 {
1180 next.push(format!(
1181 "rk upgrade --target {} takes this landing to {}",
1182 args.target,
1183 env!("CARGO_PKG_VERSION")
1184 ));
1185 }
1186 next.push(format!(
1187 "rk status --check --target {} exits 1 on a violation",
1188 args.target
1189 ));
1190 if manifest.profile.release.mode != ReleaseMode::Automatic {
1191 next.retain(|line| !line.contains("rk method operate"));
1192 }
1193 out.next(&next);
1194}
1195
1196#[cfg(test)]
1197mod tests {
1198 use super::{
1199 Classification, Drift, InvariantFailure, LegacyPath, OpenMigration, Report, SentinelAt,
1200 StalePin,
1201 };
1202 use crate::assess::{self, Facts, OwnLanding, Unrecorded};
1203 use crate::landing::CheckoutMode;
1204 use crate::landing::Integration;
1205 use crate::landing::manifest::Alignment;
1206 use crate::profile::{
1207 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
1208 };
1209
1210 #[test]
1213 #[allow(
1214 clippy::too_many_lines,
1215 reason = "the snapshot states every field of both forms in one place"
1216 )]
1217 fn the_status_report_schema_snapshot_holds() {
1218 let landed = Report {
1219 schema: super::SCHEMA,
1220 landed: true,
1221 classification: Some(Classification::Upgrade),
1222 git_work_tree: true,
1223 config: super::ConfigState {
1224 state: "pending",
1225 pending: vec!["profile.release.style".into()],
1226 },
1227 profile: Some(ProfileSnapshot {
1228 technologies: vec!["rust".into()],
1229 forge: Some("github".into()),
1230 release: ReleaseIntent {
1231 mode: ReleaseMode::Automatic,
1232 driver: Some("rust".into()),
1233 style: Some(crate::landing::Style::Trunk),
1234 line_prefix: Some("release/".into()),
1235 registry: None,
1236 },
1237 }),
1238 git: Some(GitWorkflow {
1239 trunk: "master".into(),
1240 checkout_mode: CheckoutMode::LinkedWorktree,
1241 integration: Integration::Local,
1242 merge_form: crate::landing::MergeForm::Squash,
1243 }),
1244 capabilities: Some(CapabilityRequests {
1245 nix_packaging: true,
1246 reporting_policy: true,
1247 scorecard: false,
1248 code_scanning: Some(crate::landing::Provider::Semgrep),
1249 workflow_guards: Some(false),
1250 }),
1251 selection: Some(vec![]),
1252 rk_version: Some("0.1.0".into()),
1253 binary_version: Some("0.2.0"),
1254 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
1255 drift: Some(Drift {
1256 rendered: 0,
1257 seeded: 1,
1258 }),
1259 missing: Some(vec![]),
1260 stale_pins: Some(vec![StalePin {
1261 tool: "release-plz".into(),
1262 landed: "0.3.160".into(),
1263 available: "0.3.170".into(),
1264 }]),
1265 sentinels: Some(1),
1266 sentinel_locations: Some(vec![SentinelAt {
1267 file: "release-plz.toml".into(),
1268 line: 18,
1269 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
1270 }]),
1271 record_drift: Some(0),
1272 invariant_failures: Some(vec![InvariantFailure {
1273 code: "attestations-disabled",
1274 destination: "dist-workspace.toml".into(),
1275 reason: "github-attestations is not effectively true".into(),
1276 remediation: "set github-attestations = true in [dist]",
1277 }]),
1278 warnings: Some(vec![super::Warning {
1279 code: super::CODE_SCANNING_LICENCE,
1280 reason: "the target's license, LicenseRef-proprietary, does not state a codebase this release recognizes as open source".into(),
1281 }]),
1282 setup_proof: Some(super::SetupProof {
1283 state: "stale",
1284 changed: vec!["target.required_check".into()],
1285 reason: None,
1286 detail: None,
1287 }),
1288 pending: Some(2),
1289 violations: None,
1290 legacy_state: vec![LegacyPath {
1291 path: "/state/release-kit/plans/0123456789abcdef".into(),
1292 kind: "plan",
1293 active: true,
1294 }],
1295 migration: Some(OpenMigration {
1296 id: "20261009T140205Z".into(),
1297 workspace: Some(
1298 "/state/release-kit/migrations/widget-0123456789ab/20261009T140205Z".into(),
1299 ),
1300 unfinished: Some(2),
1301 }),
1302 };
1303 assert_eq!(
1304 serde_json::to_string(&landed).expect("a report serializes"),
1305 r##"{"schema":"rk.status/14","landed":true,"classification":"upgrade","git_work_tree":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","integration":"local","merge_form":"squash"},"capabilities":{"nix_packaging":true,"reporting_policy":true,"scorecard":false,"code_scanning":"semgrep","workflow_guards":false},"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,"sentinel_locations":[{"file":"release-plz.toml","line":18,"text":"# TODO(release-kit): keep false for a binary-only crate"}],"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, does not state a codebase this release recognizes as open source"}],"setup_proof":{"state":"stale","changed":["target.required_check"]},"pending":2,"legacy_state":[{"path":"/state/release-kit/plans/0123456789abcdef","kind":"plan","active":true}],"migration":{"id":"20261009T140205Z","workspace":"/state/release-kit/migrations/widget-0123456789ab/20261009T140205Z","unfinished":2}}"##
1306 );
1307 let absent = Report {
1308 landed: false,
1309 classification: Some(Classification::Setup),
1310 git_work_tree: false,
1311 config: super::ConfigState {
1312 state: "absent",
1313 pending: vec![],
1314 },
1315 profile: None,
1316 git: None,
1317 capabilities: None,
1318 selection: None,
1319 rk_version: None,
1320 binary_version: None,
1321 alignment: None,
1322 drift: None,
1323 missing: None,
1324 stale_pins: None,
1325 sentinels: None,
1326 sentinel_locations: None,
1327 record_drift: None,
1328 invariant_failures: None,
1329 warnings: None,
1330 setup_proof: None,
1331 pending: None,
1332 violations: None,
1333 legacy_state: vec![],
1334 migration: None,
1335 ..landed
1336 };
1337 assert_eq!(
1338 serde_json::to_string(&absent).expect("a report serializes"),
1339 r#"{"schema":"rk.status/14","landed":false,"classification":"setup","git_work_tree":false,"config":{"state":"absent","pending":[]},"legacy_state":[]}"#,
1340 "an absent landing reports the fields a caller can branch on"
1341 );
1342 let newer = Report {
1343 classification: None,
1344 ..absent
1345 };
1346 assert!(
1347 serde_json::to_string(&newer)
1348 .expect("a report serializes")
1349 .contains(r#""classification":null"#),
1350 "no classification is stated as null, never omitted"
1351 );
1352 }
1353
1354 fn facts() -> Facts {
1355 Facts {
1356 technologies: Vec::new(),
1357 forge: None,
1358 repo: None,
1359 release_markers: Vec::new(),
1360 collisions: Vec::new(),
1361 git: true,
1362 tags: 0,
1363 long_lived_branches: Vec::new(),
1364 }
1365 }
1366
1367 #[test]
1372 fn every_classification_has_its_rule() {
1373 let none = OwnLanding::default();
1374 let classify = |facts: &Facts, own: &OwnLanding| {
1375 super::unrecorded(assess::classify_unrecorded(facts, own))
1376 };
1377 assert_eq!(classify(&facts(), &none), Classification::Setup);
1378 let marked = Facts {
1379 release_markers: vec![".goreleaser.yaml".into()],
1380 ..facts()
1381 };
1382 assert_eq!(classify(&marked, &none), Classification::Migration);
1383 let tagged = Facts { tags: 3, ..facts() };
1384 assert_eq!(classify(&tagged, &none), Classification::NeedsDecision);
1385 let own = OwnLanding {
1388 regions: vec!["AGENTS.md".into()],
1389 rendered: Vec::new(),
1390 };
1391 let colliding = Facts {
1392 collisions: vec!["AGENTS.md".into()],
1393 release_markers: vec!["CHANGELOG.md".into()],
1394 ..facts()
1395 };
1396 assert_eq!(classify(&colliding, &own), Classification::Adopt);
1397 assert_eq!(
1398 assess::classify_unrecorded(&colliding, &own),
1399 Unrecorded::Adopt
1400 );
1401 assert_eq!(
1402 super::recorded(Alignment::BinaryNewer, false),
1403 Some(Classification::Upgrade)
1404 );
1405 assert_eq!(
1406 super::recorded(Alignment::Aligned, false),
1407 Some(Classification::Drift)
1408 );
1409 assert_eq!(
1410 super::recorded(Alignment::Aligned, true),
1411 Some(Classification::Current)
1412 );
1413 assert_eq!(super::recorded(Alignment::TargetNewer, true), None);
1414 for (class, word) in [
1415 (Classification::Setup, "setup"),
1416 (Classification::Migration, "migration"),
1417 (Classification::NeedsDecision, "needs-decision"),
1418 (Classification::Adopt, "adopt"),
1419 (Classification::Upgrade, "upgrade"),
1420 (Classification::Drift, "drift"),
1421 (Classification::Current, "current"),
1422 ] {
1423 assert_eq!(class.as_str(), word);
1424 assert_eq!(
1425 serde_json::to_string(&class).expect("serializes"),
1426 format!("\"{word}\"")
1427 );
1428 }
1429 }
1430}