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::{assess, 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, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum Classification {
74 Setup,
76 Migration,
78 NeedsDecision,
80 Adopt,
82 Upgrade,
84 Drift,
86 Current,
88}
89
90impl Classification {
91 #[must_use]
93 pub const fn as_str(self) -> &'static str {
94 match self {
95 Self::Setup => "setup",
96 Self::Migration => "migration",
97 Self::NeedsDecision => "needs-decision",
98 Self::Adopt => "adopt",
99 Self::Upgrade => "upgrade",
100 Self::Drift => "drift",
101 Self::Current => "current",
102 }
103 }
104}
105
106const fn unrecorded(class: assess::Unrecorded) -> Classification {
109 match class {
110 assess::Unrecorded::Adopt => Classification::Adopt,
111 assess::Unrecorded::Assessed(assess::Classification::Greenfield) => Classification::Setup,
112 assess::Unrecorded::Assessed(assess::Classification::Brownfield) => {
113 Classification::Migration
114 }
115 assess::Unrecorded::Assessed(assess::Classification::NeedsDecision) => {
116 Classification::NeedsDecision
117 }
118 }
119}
120
121const fn recorded(alignment: Alignment, clean: bool) -> Option<Classification> {
125 match alignment {
126 Alignment::TargetNewer => None,
127 Alignment::BinaryNewer => Some(Classification::Upgrade),
128 Alignment::Aligned if clean => Some(Classification::Current),
129 Alignment::Aligned => Some(Classification::Drift),
130 }
131}
132
133#[derive(Debug, Clone, Serialize)]
135struct SentinelAt {
136 file: String,
138 line: usize,
140 text: String,
142}
143
144#[derive(Debug, Serialize)]
146struct LegacyPath {
147 path: String,
149 kind: &'static str,
151 active: bool,
153}
154
155fn legacy_state() -> Vec<LegacyPath> {
164 let Some(root) = crate::applog::state_root() else {
165 return Vec::new();
166 };
167 let mut found = Vec::new();
168 if let Ok(entries) = std::fs::read_dir(root.join(LEGACY_PLANS)) {
169 let mut plans: Vec<std::path::PathBuf> = entries
170 .flatten()
171 .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
172 .map(|entry| entry.path())
173 .filter(|path| path.is_dir())
174 .collect();
175 plans.sort();
176 for plan in plans {
177 let active = std::fs::read(plan.join("plan.json"))
178 .ok()
179 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
180 .is_none_or(|document| document["readiness"].as_str() == Some("ready"));
181 found.push(LegacyPath {
182 path: plan.display().to_string(),
183 kind: "plan",
184 active,
185 });
186 }
187 }
188 let cache = root.join(LEGACY_RELEASE_CACHE);
189 if cache.is_dir() {
190 found.push(LegacyPath {
191 path: cache.display().to_string(),
192 kind: "release-cache",
193 active: false,
194 });
195 }
196 found
197}
198
199const LEGACY_PLANS: &str = "plans";
201
202const LEGACY_RELEASE_CACHE: &str = "release";
204
205const SCHEMA: &str = "rk.status/13";
207
208#[derive(Debug, serde::Serialize)]
210struct ConfigState {
211 state: &'static str,
212 pending: Vec<String>,
213}
214
215fn config_state(config: Option<&crate::config::Config>, record: Option<&Manifest>) -> ConfigState {
216 let pending = config
217 .zip(record)
218 .map_or_else(Vec::new, |(config, record)| {
219 crate::config::pending(config, record)
220 });
221 ConfigState {
222 state: if config.is_none() {
223 "absent"
224 } else if pending.is_empty() {
225 "aligned"
226 } else {
227 "pending"
228 },
229 pending,
230 }
231}
232
233#[derive(Debug, Serialize)]
235struct Report {
236 schema: &'static str,
238 landed: bool,
240 classification: Option<Classification>,
243 git_work_tree: bool,
245 config: ConfigState,
246 #[serde(skip_serializing_if = "Option::is_none")]
248 profile: Option<ProfileSnapshot>,
249 #[serde(skip_serializing_if = "Option::is_none")]
251 git: Option<GitWorkflow>,
252 #[serde(skip_serializing_if = "Option::is_none")]
254 capabilities: Option<CapabilityRequests>,
255 #[serde(skip_serializing_if = "Option::is_none")]
258 selection: Option<Vec<CapabilityNote>>,
259 #[serde(skip_serializing_if = "Option::is_none")]
260 rk_version: Option<String>,
261 #[serde(skip_serializing_if = "Option::is_none")]
262 binary_version: Option<&'static str>,
263 #[serde(skip_serializing_if = "Option::is_none")]
264 alignment: Option<Alignment>,
265 #[serde(skip_serializing_if = "Option::is_none")]
266 drift: Option<Drift>,
267 #[serde(skip_serializing_if = "Option::is_none")]
269 missing: Option<Vec<String>>,
270 #[serde(skip_serializing_if = "Option::is_none")]
271 stale_pins: Option<Vec<StalePin>>,
272 #[serde(skip_serializing_if = "Option::is_none")]
274 sentinels: Option<usize>,
275 #[serde(skip_serializing_if = "Option::is_none")]
277 sentinel_locations: Option<Vec<SentinelAt>>,
278 #[serde(skip_serializing_if = "Option::is_none")]
282 record_drift: Option<usize>,
283 #[serde(skip_serializing_if = "Option::is_none")]
286 invariant_failures: Option<Vec<InvariantFailure>>,
287 #[serde(skip_serializing_if = "Option::is_none")]
291 warnings: Option<Vec<Warning>>,
292 #[serde(skip_serializing_if = "Option::is_none")]
298 pending: Option<usize>,
299 #[serde(skip_serializing_if = "Option::is_none")]
301 violations: Option<Vec<String>>,
302 legacy_state: Vec<LegacyPath>,
304 #[serde(skip_serializing_if = "Option::is_none")]
306 migration: Option<OpenMigration>,
307}
308
309#[derive(Debug, Serialize)]
311struct OpenMigration {
312 id: String,
314 #[serde(skip_serializing_if = "Option::is_none")]
316 workspace: Option<String>,
317 #[serde(skip_serializing_if = "Option::is_none")]
320 unfinished: Option<usize>,
321}
322
323fn open_migration(
328 target: &camino::Utf8Path,
329 config: Option<&crate::config::Config>,
330) -> Option<OpenMigration> {
331 let id = config.and_then(|config| config.migration.clone())?;
332 let workspace = crate::migrate::open_workspace(target).ok().flatten();
333 let unfinished = workspace
334 .as_ref()
335 .filter(|workspace| workspace.join(crate::migrate::REGISTRY_FILE).is_file())
336 .and_then(|workspace| crate::migrate::registry::load(workspace).ok())
337 .map(|registry| {
338 registry
339 .sources
340 .iter()
341 .filter(|source| !source.status.finished())
342 .count()
343 });
344 Some(OpenMigration {
345 id,
346 workspace: workspace.map(|workspace| workspace.to_string()),
347 unfinished,
348 })
349}
350
351fn render_migration(out: Output, migration: Option<&OpenMigration>) {
353 if let Some(migration) = migration {
354 out.result_line(format!(
355 "migration {} is open; rk migrate status reads it",
356 migration.id
357 ));
358 }
359}
360
361fn report_absent(
362 out: Output,
363 args: &StatusArgs,
364 config: Option<&crate::config::Config>,
365 git_work_tree: bool,
366) -> Result<(), RkError> {
367 let migration = open_migration(&args.target, config);
368 let classification = match assess::standing(&args.target, config)? {
369 assess::Standing::Migrating => Classification::Migration,
370 assess::Standing::Unrecorded(class) => unrecorded(class),
371 };
372 let legacy = legacy_state();
373 out.result_line(format!("config: {}", config_state(config, None).state));
374 out.result_line(format!("no landing at {}", args.target));
375 out.result_line(format!("classification: {}", classification.as_str()));
376 render_migration(out, migration.as_ref());
377 render_context(out, git_work_tree, &legacy);
378 out.next(&[
379 format!(
380 "rk profile --target {} reports what a landing would select",
381 args.target
382 ),
383 format!("rk init --target {} lands the workflow", args.target),
384 format!(
385 "rk adopt --target {} records a landing made before the receipt existed",
386 args.target
387 ),
388 format!(
389 "rk stage --target {} stages this binary's candidate; the rk-setup skill carries the best-effort migration",
390 args.target
391 ),
392 ]);
393 out.emit(&Report {
394 schema: SCHEMA,
395 landed: false,
396 classification: Some(classification),
397 git_work_tree,
398 config: config_state(config, None),
399 profile: None,
400 git: None,
401 capabilities: None,
402 selection: None,
403 rk_version: None,
404 binary_version: None,
405 alignment: None,
406 drift: None,
407 missing: None,
408 stale_pins: None,
409 sentinels: None,
410 sentinel_locations: None,
411 record_drift: None,
412 invariant_failures: None,
413 warnings: None,
414 pending: None,
415 violations: args.check.then(|| vec!["no landing".to_owned()]),
416 legacy_state: legacy,
417 migration,
418 })?;
419 if args.check {
420 return Err(RkError::check_failed(
421 Diagnostic::new(
422 Reason::StateDrift,
423 format!("no landing at {}, and --check requires one", args.target),
424 )
425 .expected("a target carrying .release-kit/manifest.json")
426 .action("rk init lands the workflow; rk adopt records an existing landing; rk stage and the rk-setup skill carry the migration"),
427 ));
428 }
429 Ok(())
430}
431
432struct Observed {
434 drift_rendered: Vec<String>,
435 drift_seeded: Vec<String>,
436 parameter_drift: Vec<String>,
439 record_drift: Vec<String>,
444 missing: Vec<String>,
445 stale: Vec<StalePin>,
446 sentinels: Vec<(String, usize, String)>,
447 invariants: Vec<InvariantFailure>,
448 warnings: Vec<Warning>,
450 incompatible: Vec<String>,
456 pending: Option<Vec<String>>,
460 selection: Option<Vec<CapabilityNote>>,
462}
463
464pub fn run(args: &StatusArgs) -> Result<(), RkError> {
473 let out = Output::new(args.json);
474 if !args.target.is_dir() {
475 return Err(RkError::missing(
476 Diagnostic::new(
477 Reason::TargetNotFound,
478 format!("target {} is not a directory", args.target),
479 )
480 .expected("an existing repository to report on"),
481 ));
482 }
483 let config = crate::config::load(args.target.as_std_path())?;
484 let git_work_tree = assess::git_work_tree(&args.target)?;
485 let Some(manifest) = manifest::load(&args.target)? else {
486 return report_absent(out, args, config.as_ref(), git_work_tree);
487 };
488
489 let migration = open_migration(&args.target, config.as_ref());
490 let config = config_state(config.as_ref(), Some(&manifest));
491 out.result_line(format!("config: {}", config.state));
492 for key in &config.pending {
493 out.result_line(format!(
494 "config pending: {key}; rk upgrade --apply takes it up"
495 ));
496 }
497 let observed = observe(args, &manifest)?;
498 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
499 render_human(out, args, &manifest, alignment, &observed);
500
501 let violations = violations_of(&observed);
502 let classification = if migration.is_some() && alignment != Alignment::TargetNewer {
506 Some(Classification::Migration)
507 } else {
508 recorded(alignment, violations.is_empty())
509 };
510 out.result_line(format!(
511 "classification: {}",
512 classification.map_or(
513 "none: the record is newer than this binary",
514 Classification::as_str
515 )
516 ));
517 render_migration(out, migration.as_ref());
518 let legacy = legacy_state();
519 render_context(out, git_work_tree, &legacy);
520 out.emit(&Report {
521 schema: SCHEMA,
522 landed: true,
523 classification,
524 git_work_tree,
525 config,
526 profile: Some(manifest.profile.clone()),
527 git: Some(manifest.git.clone()),
528 capabilities: Some(manifest.capabilities.clone()),
529 selection: observed.selection.clone(),
530 rk_version: Some(manifest.rk_version),
531 binary_version: Some(env!("CARGO_PKG_VERSION")),
532 alignment: Some(alignment),
533 drift: Some(Drift {
534 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
535 seeded: observed.drift_seeded.len(),
536 }),
537 record_drift: Some(observed.record_drift.len()),
538 missing: Some(observed.missing.clone()),
539 stale_pins: Some(observed.stale),
540 sentinels: Some(observed.sentinels.len()),
541 sentinel_locations: Some(
542 observed
543 .sentinels
544 .iter()
545 .map(|(file, line, text)| SentinelAt {
546 file: file.clone(),
547 line: *line,
548 text: text.clone(),
549 })
550 .collect(),
551 ),
552 invariant_failures: Some(observed.invariants),
553 warnings: Some(observed.warnings),
554 pending: observed.pending.as_ref().map(Vec::len),
555 violations: args.check.then(|| violations.clone()),
556 legacy_state: legacy,
557 migration,
558 })?;
559
560 if args.check && !violations.is_empty() {
561 return Err(RkError::check_failed(
562 Diagnostic::new(
563 Reason::StateDrift,
564 format!(
565 "the landing is not clean: {} violation{}",
566 violations.len(),
567 if violations.len() == 1 { "" } else { "s" }
568 ),
569 )
570 .expected(
571 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
572 ),
573 ));
574 }
575 Ok(())
576}
577
578fn render_context(out: Output, git_work_tree: bool, legacy: &[LegacyPath]) {
582 if !git_work_tree {
583 out.result_line("not a non-bare Git work tree: the method lands files a work tree commits");
584 }
585 for entry in legacy {
586 out.result_line(format!(
587 "LEGACY {} ({}, {})",
588 entry.path,
589 entry.kind,
590 if entry.active { "active" } else { "inactive" }
591 ));
592 }
593}
594
595fn violations_of(observed: &Observed) -> Vec<String> {
599 observed
600 .drift_rendered
601 .iter()
602 .map(|path| format!("rendered drift: {path}"))
603 .chain(
604 observed
605 .parameter_drift
606 .iter()
607 .map(|path| format!("parameter drift: {path}")),
608 )
609 .chain(
610 observed
611 .record_drift
612 .iter()
613 .map(|reason| format!("record drift: {reason}")),
614 )
615 .chain(
616 observed
617 .missing
618 .iter()
619 .map(|path| format!("missing: {path}")),
620 )
621 .chain(
622 observed
623 .sentinels
624 .iter()
625 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
626 )
627 .chain(
628 observed
629 .invariants
630 .iter()
631 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
632 )
633 .collect()
634}
635
636#[allow(
639 clippy::too_many_lines,
640 reason = "one pass over the record and the disk answers every comparison the report states"
641)]
642fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
643 let mut observed = Observed {
644 drift_rendered: Vec::new(),
645 drift_seeded: Vec::new(),
646 parameter_drift: Vec::new(),
647 record_drift: Vec::new(),
648 missing: Vec::new(),
649 stale: Vec::new(),
650 sentinels: Vec::new(),
651 invariants: Vec::new(),
652 warnings: Vec::new(),
653 incompatible: Vec::new(),
654 pending: None,
655 selection: None,
656 };
657 let aligned =
663 manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION")) == Alignment::Aligned;
664 let projected = match project(args, manifest) {
665 Ok(projection) => Some(projection),
666 Err(err) if aligned => return Err(err),
667 Err(_) => None,
668 };
669 for file in &manifest.files {
670 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
671 observed.missing.push(file.destination.clone());
672 continue;
673 };
674 if Digest::of(&bytes) != file.sha256 {
675 match file.kind {
676 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
677 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
678 Kind::State => {}
679 }
680 }
681 observed.invariants.extend(invariants::failures(
682 manifest.profile.release.driver.as_deref().unwrap_or(""),
683 manifest.profile.forge.as_deref().unwrap_or(""),
684 &file.destination,
685 &bytes,
686 ));
687 let text = String::from_utf8_lossy(&bytes);
688 for (idx, line) in text.lines().enumerate() {
689 if line.contains(embedded::SENTINEL) {
690 observed.sentinels.push((
691 file.destination.clone(),
692 idx + 1,
693 line.trim().to_owned(),
694 ));
695 }
696 }
697 let defective = projected.as_ref().is_some_and(|projection| {
702 projection
703 .collisions
704 .iter()
705 .any(|collision| collision.destination == file.destination)
706 });
707 if defective && !observed.drift_rendered.contains(&file.destination) {
708 observed.drift_rendered.push(file.destination.clone());
709 }
710 }
711 observed.invariants.extend(invariants::target_failures(
716 manifest.profile.release.driver.as_deref().unwrap_or(""),
717 manifest.profile.forge.as_deref().unwrap_or(""),
718 &args.target,
719 ));
720 observed.selection = projected.as_ref().map(|projection| {
721 projection
722 .capabilities
723 .iter()
724 .map(|selection| CapabilityNote::of(selection, projection))
725 .collect()
726 });
727 if let Some(reason) = projected
728 .as_ref()
729 .and_then(|projection| projection.licence_refusal.clone())
730 {
731 observed.warnings.push(Warning {
732 code: CODE_SCANNING_LICENCE,
733 reason,
734 });
735 }
736 if let Some(projection) = projected.as_ref() {
741 observed.incompatible.clone_from(&projection.record_defects);
742 observed
743 .record_drift
744 .extend(projection.record_defects.iter().cloned());
745 }
746 if aligned && let Some(projection) = projected.as_ref() {
747 observe_parameter_drift(manifest, projection, &mut observed);
748 observe_record_set(manifest, projection, &mut observed.record_drift);
749 }
750 observed.pending = projected
755 .as_ref()
756 .map(|projection| pending_of(manifest, &projection.candidates));
757 for (tool, landed) in &manifest.pins {
761 if let Some(available) = registry::version_of(tool)
762 && manifest::version_is_newer(&available, landed)
763 {
764 observed.stale.push(StalePin {
765 tool: tool.clone(),
766 landed: landed.clone(),
767 available,
768 });
769 }
770 }
771 Ok(observed)
772}
773
774fn observe_parameter_drift(manifest: &Manifest, projection: &Projection, observed: &mut Observed) {
786 for candidate in &projection.candidates {
787 if candidate.kind != Kind::Rendered {
788 continue;
789 }
790 let Some(record) = manifest.file(&candidate.destination) else {
791 continue;
792 };
793 if observed.drift_rendered.contains(&candidate.destination)
794 || observed.missing.contains(&candidate.destination)
795 {
796 continue;
797 }
798 if Digest::of(recorded_form(candidate)) != record.sha256 {
799 observed
800 .parameter_drift
801 .push(format!("{} (parameters)", candidate.destination));
802 }
803 }
804}
805
806fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Projection, RkError> {
810 let evidence = TargetEvidence::gather(&args.target, Some(manifest))?;
811 Projection::compute(&ProjectionInput {
812 params: landing::Params::from_record(manifest),
813 evidence,
814 })
815}
816
817fn recorded_form(candidate: &Candidate) -> &[u8] {
820 candidate.region.as_deref().unwrap_or(&candidate.bytes)
821}
822
823fn pending_of(manifest: &Manifest, projected: &[Candidate]) -> Vec<String> {
832 let mut pending = Vec::new();
833 for entry in projected {
834 let changed = manifest.file(&entry.destination).is_none_or(|record| {
835 record.kind != entry.kind
836 || (entry.kind == Kind::Rendered
837 && record.sha256 != Digest::of(recorded_form(entry)))
838 });
839 if changed {
840 pending.push(entry.destination.clone());
841 }
842 }
843 for file in &manifest.files {
844 if !projected
845 .iter()
846 .any(|entry| entry.destination == file.destination)
847 {
848 pending.push(file.destination.clone());
849 }
850 }
851 pending.sort();
852 pending.dedup();
853 pending
854}
855
856fn observe_record_set(
868 manifest: &Manifest,
869 projection: &Projection,
870 record_drift: &mut Vec<String>,
871) {
872 for entry in &projection.candidates {
873 if manifest.file(&entry.destination).is_none() {
874 record_drift.push(format!(
875 "the recorded parameters project {}, which the receipt does not name",
876 entry.destination
877 ));
878 }
879 }
880 for file in &manifest.files {
881 let produced = projection
882 .candidates
883 .iter()
884 .any(|entry| entry.destination == file.destination)
885 || projection
886 .omissions
887 .iter()
888 .any(|omission| omission.destination == file.destination);
889 if !produced {
890 record_drift.push(format!(
891 "the receipt names {}, which the recorded parameters do not project",
892 file.destination
893 ));
894 }
895 }
896}
897
898#[allow(
900 clippy::too_many_lines,
901 reason = "one pass prints every reportable condition in the order the report states them"
902)]
903fn render_human(
904 out: Output,
905 args: &StatusArgs,
906 manifest: &Manifest,
907 alignment: Alignment,
908 observed: &Observed,
909) {
910 out.result_line(format!(
911 "release-kit {} at {}: {}",
912 manifest.rk_version,
913 args.target,
914 crate::commands::profile::describe(
915 &manifest.profile,
916 &manifest.git,
917 &manifest.capabilities,
918 &manifest.parameters.repo
919 )
920 ));
921 for note in observed.selection.iter().flatten() {
925 if note.status != "selected" {
926 out.result_line(format!(
927 "capability {}: {}{}",
928 note.id,
929 note.status,
930 note.reason
931 .as_deref()
932 .map_or_else(String::new, |reason| format!(" ({reason})"))
933 ));
934 }
935 }
936 if alignment == Alignment::TargetNewer {
937 out.result_line(format!(
938 "binary {} is older than this landing; install the matching rk",
939 env!("CARGO_PKG_VERSION")
940 ));
941 }
942 match observed.pending.as_deref() {
943 None => out.result_line(format!(
944 "this binary carries no projection for the recorded {} release on {}, so what an upgrade would change is unknown",
945 manifest
946 .profile
947 .release
948 .driver
949 .as_deref()
950 .unwrap_or("release-less"),
951 manifest.profile.forge.as_deref().unwrap_or("no forge")
952 )),
953 Some(paths) => {
954 for path in paths {
955 out.result_line(format!("PENDING {path} (this binary would change it)"));
956 }
957 }
958 }
959 for path in &observed.drift_rendered {
960 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
961 }
962 for path in &observed.parameter_drift {
963 out.result_line(format!(
964 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
965 ));
966 }
967 for reason in &observed.record_drift {
968 out.result_line(format!("DRIFT record: {reason}"));
969 }
970 for path in &observed.drift_seeded {
971 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
972 }
973 for path in &observed.missing {
974 out.result_line(format!("MISSING {path}"));
975 }
976 for pin in &observed.stale {
977 out.result_line(format!(
978 "STALE {} {} landed, {} in this binary",
979 pin.tool, pin.landed, pin.available
980 ));
981 }
982 for (path, line, text) in &observed.sentinels {
983 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
984 }
985 for failure in &observed.invariants {
986 out.result_line(format!(
987 "INVARIANT {} ({}): {}",
988 failure.destination, failure.code, failure.reason
989 ));
990 }
991 for warning in &observed.warnings {
992 out.result_line(format!("WARNING ({}): {}", warning.code, warning.reason));
993 }
994 let mut next = Vec::new();
995 for failure in &observed.invariants {
996 next.push(format!("{}: {}", failure.destination, failure.remediation));
997 }
998 if !observed.incompatible.is_empty() {
1002 next.push(format!(
1003 "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",
1004 args.target
1005 ));
1006 }
1007 if observed.record_drift.len() > observed.incompatible.len() {
1008 next.push(format!(
1009 "rk upgrade --target {} rewrites the receipt from its parameters",
1010 args.target
1011 ));
1012 }
1013 if observed
1014 .pending
1015 .as_deref()
1016 .is_none_or(|paths| !paths.is_empty())
1017 {
1018 next.push(format!(
1019 "rk upgrade --target {} takes this landing to {}",
1020 args.target,
1021 env!("CARGO_PKG_VERSION")
1022 ));
1023 }
1024 next.push(format!(
1025 "rk status --check --target {} exits 1 on a violation",
1026 args.target
1027 ));
1028 if manifest.profile.release.mode != ReleaseMode::Automatic {
1029 next.retain(|line| !line.contains("rk method operate"));
1030 }
1031 out.next(&next);
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use super::{
1037 Classification, Drift, InvariantFailure, LegacyPath, OpenMigration, Report, SentinelAt,
1038 StalePin,
1039 };
1040 use crate::assess::{self, Facts, OwnLanding, Unrecorded};
1041 use crate::landing::CheckoutMode;
1042 use crate::landing::Integration;
1043 use crate::landing::manifest::Alignment;
1044 use crate::profile::{
1045 CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
1046 };
1047
1048 #[test]
1051 #[allow(
1052 clippy::too_many_lines,
1053 reason = "the snapshot states every field of both forms in one place"
1054 )]
1055 fn the_status_report_schema_snapshot_holds() {
1056 let landed = Report {
1057 schema: super::SCHEMA,
1058 landed: true,
1059 classification: Some(Classification::Upgrade),
1060 git_work_tree: true,
1061 config: super::ConfigState {
1062 state: "pending",
1063 pending: vec!["profile.release.style".into()],
1064 },
1065 profile: Some(ProfileSnapshot {
1066 technologies: vec!["rust".into()],
1067 forge: Some("github".into()),
1068 release: ReleaseIntent {
1069 mode: ReleaseMode::Automatic,
1070 driver: Some("rust".into()),
1071 style: Some(crate::landing::Style::Trunk),
1072 line_prefix: Some("release/".into()),
1073 },
1074 }),
1075 git: Some(GitWorkflow {
1076 trunk: "master".into(),
1077 checkout_mode: CheckoutMode::LinkedWorktree,
1078 integration: Integration::Local,
1079 merge_form: crate::landing::MergeForm::Squash,
1080 }),
1081 capabilities: Some(CapabilityRequests {
1082 nix_packaging: true,
1083 reporting_policy: true,
1084 scorecard: false,
1085 code_scanning: Some(crate::landing::Provider::Semgrep),
1086 workflow_guards: Some(false),
1087 }),
1088 selection: Some(vec![]),
1089 rk_version: Some("0.1.0".into()),
1090 binary_version: Some("0.2.0"),
1091 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
1092 drift: Some(Drift {
1093 rendered: 0,
1094 seeded: 1,
1095 }),
1096 missing: Some(vec![]),
1097 stale_pins: Some(vec![StalePin {
1098 tool: "release-plz".into(),
1099 landed: "0.3.160".into(),
1100 available: "0.3.170".into(),
1101 }]),
1102 sentinels: Some(1),
1103 sentinel_locations: Some(vec![SentinelAt {
1104 file: "release-plz.toml".into(),
1105 line: 18,
1106 text: "# TODO(release-kit): keep false for a binary-only crate".into(),
1107 }]),
1108 record_drift: Some(0),
1109 invariant_failures: Some(vec![InvariantFailure {
1110 code: "attestations-disabled",
1111 destination: "dist-workspace.toml".into(),
1112 reason: "github-attestations is not effectively true".into(),
1113 remediation: "set github-attestations = true in [dist]",
1114 }]),
1115 warnings: Some(vec![super::Warning {
1116 code: super::CODE_SCANNING_LICENCE,
1117 reason: "the target's license, LicenseRef-proprietary, does not state a codebase this release recognizes as open source".into(),
1118 }]),
1119 pending: Some(2),
1120 violations: None,
1121 legacy_state: vec![LegacyPath {
1122 path: "/state/release-kit/plans/0123456789abcdef".into(),
1123 kind: "plan",
1124 active: true,
1125 }],
1126 migration: Some(OpenMigration {
1127 id: "20261009T140205Z".into(),
1128 workspace: Some(
1129 "/state/release-kit/migrations/widget-0123456789ab/20261009T140205Z".into(),
1130 ),
1131 unfinished: Some(2),
1132 }),
1133 };
1134 assert_eq!(
1135 serde_json::to_string(&landed).expect("a report serializes"),
1136 r##"{"schema":"rk.status/13","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"}],"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}}"##
1137 );
1138 let absent = Report {
1139 landed: false,
1140 classification: Some(Classification::Setup),
1141 git_work_tree: false,
1142 config: super::ConfigState {
1143 state: "absent",
1144 pending: vec![],
1145 },
1146 profile: None,
1147 git: None,
1148 capabilities: None,
1149 selection: None,
1150 rk_version: None,
1151 binary_version: None,
1152 alignment: None,
1153 drift: None,
1154 missing: None,
1155 stale_pins: None,
1156 sentinels: None,
1157 sentinel_locations: None,
1158 record_drift: None,
1159 invariant_failures: None,
1160 warnings: None,
1161 pending: None,
1162 violations: None,
1163 legacy_state: vec![],
1164 migration: None,
1165 ..landed
1166 };
1167 assert_eq!(
1168 serde_json::to_string(&absent).expect("a report serializes"),
1169 r#"{"schema":"rk.status/13","landed":false,"classification":"setup","git_work_tree":false,"config":{"state":"absent","pending":[]},"legacy_state":[]}"#,
1170 "an absent landing reports the fields a caller can branch on"
1171 );
1172 let newer = Report {
1173 classification: None,
1174 ..absent
1175 };
1176 assert!(
1177 serde_json::to_string(&newer)
1178 .expect("a report serializes")
1179 .contains(r#""classification":null"#),
1180 "no classification is stated as null, never omitted"
1181 );
1182 }
1183
1184 fn facts() -> Facts {
1185 Facts {
1186 technologies: Vec::new(),
1187 forge: None,
1188 repo: None,
1189 release_markers: Vec::new(),
1190 collisions: Vec::new(),
1191 git: true,
1192 tags: 0,
1193 long_lived_branches: Vec::new(),
1194 }
1195 }
1196
1197 #[test]
1202 fn every_classification_has_its_rule() {
1203 let none = OwnLanding::default();
1204 let classify = |facts: &Facts, own: &OwnLanding| {
1205 super::unrecorded(assess::classify_unrecorded(facts, own))
1206 };
1207 assert_eq!(classify(&facts(), &none), Classification::Setup);
1208 let marked = Facts {
1209 release_markers: vec![".goreleaser.yaml".into()],
1210 ..facts()
1211 };
1212 assert_eq!(classify(&marked, &none), Classification::Migration);
1213 let tagged = Facts { tags: 3, ..facts() };
1214 assert_eq!(classify(&tagged, &none), Classification::NeedsDecision);
1215 let own = OwnLanding {
1218 regions: vec!["AGENTS.md".into()],
1219 rendered: Vec::new(),
1220 };
1221 let colliding = Facts {
1222 collisions: vec!["AGENTS.md".into()],
1223 release_markers: vec!["CHANGELOG.md".into()],
1224 ..facts()
1225 };
1226 assert_eq!(classify(&colliding, &own), Classification::Adopt);
1227 assert_eq!(
1228 assess::classify_unrecorded(&colliding, &own),
1229 Unrecorded::Adopt
1230 );
1231 assert_eq!(
1232 super::recorded(Alignment::BinaryNewer, false),
1233 Some(Classification::Upgrade)
1234 );
1235 assert_eq!(
1236 super::recorded(Alignment::Aligned, false),
1237 Some(Classification::Drift)
1238 );
1239 assert_eq!(
1240 super::recorded(Alignment::Aligned, true),
1241 Some(Classification::Current)
1242 );
1243 assert_eq!(super::recorded(Alignment::TargetNewer, true), None);
1244 for (class, word) in [
1245 (Classification::Setup, "setup"),
1246 (Classification::Migration, "migration"),
1247 (Classification::NeedsDecision, "needs-decision"),
1248 (Classification::Adopt, "adopt"),
1249 (Classification::Upgrade, "upgrade"),
1250 (Classification::Drift, "drift"),
1251 (Classification::Current, "current"),
1252 ] {
1253 assert_eq!(class.as_str(), word);
1254 assert_eq!(
1255 serde_json::to_string(&class).expect("serializes"),
1256 format!("\"{word}\"")
1257 );
1258 }
1259 }
1260}