Skip to main content

release_kit/commands/
status.rs

1//! `rk status`: a target describes itself from its own disk.
2//!
3//! Read-only and offline: the receipt supplies what landed, this binary's
4//! projection supplies what an upgrade would offer, the embedded registry
5//! supplies the pin comparison, and no network is ever touched: a fetch
6//! is a way for a status command to hang, fail on a network it should not
7//! need, or leak a repository's existence. Status compares the current
8//! target with this binary's projection and the receipt and nothing else;
9//! it reads no stage and resolves no other release. Plain `rk status`
10//! reports and exits 0 for every reportable state, drift and no-landing
11//! included; `--check` computes the identical report and changes only
12//! the final judgment, the one sanctioned bare exit 1.
13//!
14//! SATISFIES landing:status-judges-only-under-check
15
16use 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/// Drift counts by owned kind; `state` files are never compared.
32#[derive(Debug, Serialize)]
33struct Drift {
34    /// Edits to files release-kit owns — the violation class.
35    rendered: usize,
36    /// Edits to files the target owns — expected and informational.
37    seeded: usize,
38}
39
40/// One recorded pin that is behind this binary's registry.
41#[derive(Debug, Serialize)]
42struct StalePin {
43    /// The tool's registry name.
44    tool: String,
45    /// The version the landing recorded.
46    landed: String,
47    /// The version this binary's registry pins.
48    available: String,
49}
50
51/// One reportable condition that is not a violation: the target is not
52/// broken and the decision behind it is the operator's.
53///
54/// The `code` is stable, so a machine reader branches on it rather than on
55/// the prose.
56#[derive(Debug, Serialize)]
57struct Warning {
58    /// The stable reason code.
59    code: &'static str,
60    /// What the condition is, in the target's own terms.
61    reason: String,
62}
63
64/// The code a lapsed code scanning licence reports under.
65const CODE_SCANNING_LICENCE: &str = "code-scanning-licence";
66
67/// Where a target stands for the setup skill's gate: one word computed by
68/// the CLI's own rule, so no reader branches on five fields by hand.
69///
70/// SATISFIES landing:a-landing-classifies-its-target-first
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
72#[serde(rename_all = "kebab-case")]
73pub enum Classification {
74    /// No record, no release mechanism, and no release history.
75    Setup,
76    /// No record and another release mechanism in place.
77    Migration,
78    /// No record and release activity no mechanism explains.
79    NeedsDecision,
80    /// No record, and release-kit's own landing is present.
81    Adopt,
82    /// A record older than this binary.
83    Upgrade,
84    /// A record at this binary's version that `--check` fails.
85    Drift,
86    /// A record at this binary's version that `--check` passes.
87    Current,
88}
89
90impl Classification {
91    /// The kebab-case word, as the JSON serializes it.
92    #[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
106/// The classification of a target with no record, from the assessment's
107/// rule and the adoption detector.
108const 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
121/// The classification of a recorded target. A record newer than this
122/// binary has none: that is an observation the setup skill routes to
123/// provisioning, not a state this binary can judge.
124const 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/// One unfilled judgment sentinel, located.
134#[derive(Debug, Clone, Serialize)]
135struct SentinelAt {
136    /// The landed file that carries it.
137    file: String,
138    /// Its line, counted from one.
139    line: usize,
140    /// The line's text, trimmed.
141    text: String,
142}
143
144/// One path a 0.4.x release left under the state root.
145#[derive(Debug, Serialize)]
146struct LegacyPath {
147    /// The absolute path.
148    path: String,
149    /// `plan` for a stored plan, `release-cache` for the release cache.
150    kind: &'static str,
151    /// Whether it holds an operation the old binary could still apply.
152    active: bool,
153}
154
155/// The state a 0.4.x release stored under the state root, each path with
156/// whether an operation is active. No later release writes `plans/` or
157/// `release/`, so what is there is the old binary's.
158///
159/// A stored plan reads as active while its own document says an apply
160/// may run it, and while it cannot be read at all, because only the
161/// operator can retire an operation the old binary owns. The release
162/// cache holds no operation.
163fn 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
199/// Where a 0.4.x release stored its plans, under the state root.
200const LEGACY_PLANS: &str = "plans";
201
202/// Where a 0.4.x release cached fetched releases, under the state root.
203const LEGACY_RELEASE_CACHE: &str = "release";
204
205/// The status report's shape version.
206const SCHEMA: &str = "rk.status/13";
207
208/// Configuration is informational, independent of every drift comparison.
209#[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/// The machine form of a status report.
234#[derive(Debug, Serialize)]
235struct Report {
236    /// The shape version of this document.
237    schema: &'static str,
238    /// Whether a landing record exists; most other fields need one.
239    landed: bool,
240    /// Where the target stands for the setup skill's gate; null where the
241    /// record is newer than this binary.
242    classification: Option<Classification>,
243    /// Whether the target sits in a non-bare Git work tree.
244    git_work_tree: bool,
245    config: ConfigState,
246    /// What the record says the project is.
247    #[serde(skip_serializing_if = "Option::is_none")]
248    profile: Option<ProfileSnapshot>,
249    /// The recorded Git workflow.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    git: Option<GitWorkflow>,
252    /// The recorded capability requests.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    capabilities: Option<CapabilityRequests>,
255    /// Every capability the catalog answers for the recorded values, in
256    /// catalog order; absent where this binary cannot project the record.
257    #[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    /// Recorded destinations absent from the disk.
268    #[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    /// Unresolved judgment sentinels across the landed files.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    sentinels: Option<usize>,
275    /// Each unresolved sentinel, by file and line.
276    #[serde(skip_serializing_if = "Option::is_none")]
277    sentinel_locations: Option<Vec<SentinelAt>>,
278    /// Record-set disagreements between the recorded parameters'
279    /// projection and the recorded destinations — its own count, because
280    /// no file was edited and the kind counts must stay honest.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    record_drift: Option<usize>,
283    /// Invariants a landed file's effective configuration violates —
284    /// judged, never rewritten, because the file stays the target's.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    invariant_failures: Option<Vec<InvariantFailure>>,
287    /// Conditions that are reportable and not violations: `--check` exits 0
288    /// on them, because the target is not broken and the decision is the
289    /// operator's.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    warnings: Option<Vec<Warning>>,
292    /// How many destinations an upgrade would change: the count this
293    /// binary's sources project under the recorded parameters against
294    /// what the record names. Zero means nothing to take, whatever the two
295    /// versions say. Absent on a landed target means this binary carries
296    /// no files for the recorded pair and cannot answer.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pending: Option<usize>,
299    /// Present only under `--check`: what the judgment failed on.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    violations: Option<Vec<String>>,
302    /// The state a 0.4.x release left under the state root.
303    legacy_state: Vec<LegacyPath>,
304    /// The open migration, where the configuration names one.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    migration: Option<OpenMigration>,
307}
308
309/// An open migration, as the status reports it.
310#[derive(Debug, Serialize)]
311struct OpenMigration {
312    /// The identifier the configuration names.
313    id: String,
314    /// The workspace on this host, absent where this host holds none.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    workspace: Option<String>,
317    /// How many sources are neither verified nor dropped, absent where
318    /// this host holds no registry.
319    #[serde(skip_serializing_if = "Option::is_none")]
320    unfinished: Option<usize>,
321}
322
323/// The open migration the configuration names, read from this host's
324/// workspace where it holds one.
325///
326/// SATISFIES migration:the-configuration-names-the-open-migration
327fn 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
351/// The line a human report carries for an open migration.
352fn 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
432/// What one pass over the record and the disk observed.
433struct Observed {
434    drift_rendered: Vec<String>,
435    drift_seeded: Vec<String>,
436    /// Recorded block destinations whose recorded digest the record's own
437    /// parameters do not reproduce: the record was edited, not the file.
438    parameter_drift: Vec<String>,
439    /// Set differences between what the recorded parameters project —
440    /// the withhold judgment applied — and the destinations the record
441    /// names: a record whose parameters and file list disagree, whichever
442    /// of the two was edited or outgrown.
443    record_drift: Vec<String>,
444    missing: Vec<String>,
445    stale: Vec<StalePin>,
446    sentinels: Vec<(String, usize, String)>,
447    invariants: Vec<InvariantFailure>,
448    /// Reportable conditions that are not violations.
449    warnings: Vec<Warning>,
450    /// Recorded intents this target cannot honour, counted in
451    /// `record_drift` and kept separately because a plain upgrade cannot
452    /// repair them: it re-reads the same recorded answer and refuses. An
453    /// unavailable optional capability is not one of these; it is reported
454    /// in `selection` and omitted, and an upgrade runs over it unharmed.
455    incompatible: Vec<String>,
456    /// The destinations an upgrade would change, or `None` where this
457    /// binary carries no projection for the recorded pair and so cannot
458    /// say.
459    pending: Option<Vec<String>>,
460    /// Every capability the catalog answers for the recorded values.
461    selection: Option<Vec<CapabilityNote>>,
462}
463
464/// Report the target's landing.
465///
466/// # Errors
467///
468/// Returns [`RkError::Missing`] for a target that is not a directory, the
469/// record's own failure taxonomy for an unreadable or unknown record, and
470/// [`RkError::CheckFailed`] under `--check` when the report holds a
471/// violation.
472pub 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    // A record newer than this binary is no state it can judge, an open
503    // migration included; below that, the open migration outranks the
504    // record until it closes.
505    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
578/// The human lines every report carries, landed or not: whether the
579/// target is a work tree the method can land in, and each path a 0.4.x
580/// release left under the state root.
581fn 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
595/// The check-mode violation lines: rendered drift, missing recorded
596/// files, unresolved sentinels, and invariant failures — the closed set
597/// `landing:status-judges-only-under-check` names.
598fn 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/// One pass over the record and the disk: drift, missing files, stale
637/// pins, and sentinels.
638#[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    // One projection serves every reader below, because each asks what
658    // this binary makes of the recorded parameters at this target. A pair
659    // this binary does not carry cannot be projected at all: under a
660    // receipt this binary wrote that is a defect and still fails, and
661    // under a landing from another rk it is a fact to report.
662    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        // A marked document's markers must be well formed even when its
698        // first block matches the receipt: a duplicate hook block still
699        // executes, so an ill-formed document reads as rendered drift,
700        // never as clean.
701        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    // The cross-file step: a landed file can generate the artifact the
712    // forge actually executes, and the seeds ship no copy of it, so no
713    // recorded digest sees the two disagree. The pair's own rule reads
714    // both off the target's disk.
715    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    // A receipt naming a release intent nothing can honour is judged at
737    // every alignment rather than only where this binary wrote the record.
738    // An unavailable optional capability is not one of these: it reports
739    // through `selection` and lands nothing, and no verb refuses on it.
740    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    // What an upgrade would change, which is the only honest ground for
751    // telling an operator to run one. The recorded version says who wrote
752    // the receipt, and two releases apart can carry identical bytes for
753    // this pair, so it answers a different question and prompts nothing.
754    observed.pending = projected
755        .as_ref()
756        .map(|projection| pending_of(manifest, &projection.candidates));
757    // Stale means behind, not merely different: a landing from a newer rk
758    // can carry pins ahead of this binary's registry, and that is the
759    // alignment line's story, not a freshness complaint.
760    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
774/// The receipt-consistency step over every rendered destination.
775///
776/// Recorded digests alone cannot see a receipt edited only at its
777/// parameters, since every file still matches its own record, so every
778/// rendered candidate, whole file or region, as this projection renders
779/// it from the receipt's own parameters, is compared against the digest
780/// the receipt stores for it. Called only where this binary wrote the
781/// receipt: an older landing's files legitimately differ from this
782/// projection, which is the alignment line's story and the upgrade's job,
783/// not parameter drift. A destination already reported as rendered drift
784/// is the file's own story, not the receipt's, and is skipped too.
785fn 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
806/// What this binary projects under the receipt's own parameters at this
807/// target, with the same withhold judgment a landing applies, so the
808/// comparison stands against what an upgrade would actually offer.
809fn 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
817/// The bytes the receipt digests for a candidate: the whole file, or the
818/// marked region alone.
819fn recorded_form(candidate: &Candidate) -> &[u8] {
820    candidate.region.as_deref().unwrap_or(&candidate.bytes)
821}
822
823/// The destinations an upgrade would change, read off the record alone.
824///
825/// A destination the projection adds or drops changes the record either
826/// way, and a `rendered` one whose candidate digest differs from the
827/// recorded digest is rewritten. A `seeded` or `state` destination the
828/// record already names is never rewritten, so only a change of kind
829/// counts for it. Disk drift is a separate story, told by its own lines:
830/// an edited file is the target's doing, not a newer binary's.
831fn 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
856/// The receipt-set consistency step: the recorded digests judge each
857/// named file, and the region re-render judges the region records, but
858/// neither can see a receipt whose parameters and file list disagree, a
859/// nix flag flipped in the receipt with no file landed, or a
860/// once-withheld capability whose target grew into the supported shape.
861/// So the projection is computed from the receipt's own parameters, the
862/// same withhold judgment applied, and the two destination sets compared
863/// both ways. A destination the projection withholds at this target is
864/// absent because withheld, which is not drift. Called only where this
865/// binary wrote the receipt: an older landing's set legitimately differs,
866/// and that is the alignment line's story.
867fn 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/// The human lines, identical with and without `--check`.
899#[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    // Every capability the record does not select is information, never
922    // a violation: a product nobody asked for, or one this target's
923    // dimensions cannot take.
924    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    // The incompatible ones first, and with the override: a plain upgrade
999    // reads the same recorded intent and refuses, so advertising it alone
1000    // would send the operator into a loop.
1001    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    /// The complete `rk.status/13` shape, held by snapshot in both the
1049    /// landed and absent forms.
1050    #[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    /// Each value the gate reads has one rule that earns it: the
1198    /// assessment's three for a target with no record, the adoption
1199    /// detector before them, and the alignment and the check for a
1200    /// recorded one.
1201    #[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        // release-kit's own regions read as collisions to the assessment,
1216        // and the detector outranks them.
1217        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}