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::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/// Drift counts by owned kind; `state` files are never compared.
33#[derive(Debug, Serialize)]
34struct Drift {
35    /// Edits to files release-kit owns — the violation class.
36    rendered: usize,
37    /// Edits to files the target owns — expected and informational.
38    seeded: usize,
39}
40
41/// One recorded pin that is behind this binary's registry.
42#[derive(Debug, Serialize)]
43struct StalePin {
44    /// The tool's registry name.
45    tool: String,
46    /// The version the landing recorded.
47    landed: String,
48    /// The version this binary's registry pins.
49    available: String,
50}
51
52/// One reportable condition that is not a violation: the target is not
53/// broken and the decision behind it is the operator's.
54///
55/// The `code` is stable, so a machine reader branches on it rather than on
56/// the prose.
57#[derive(Debug, Serialize)]
58struct Warning {
59    /// The stable reason code.
60    code: &'static str,
61    /// What the condition is, in the target's own terms.
62    reason: String,
63}
64
65/// Where the committed setup proof stands against the contract the target
66/// states now, read offline.
67///
68/// SATISFIES setup-proof:the-status-check-holds-the-proof
69#[derive(Debug, Clone, Serialize)]
70struct SetupProof {
71    /// `compatible`, `stale`, `invalid`, or `absent`.
72    state: &'static str,
73    /// Each changed field of a stale proof.
74    #[serde(skip_serializing_if = "Vec::is_empty")]
75    changed: Vec<String>,
76    /// Why an invalid proof cannot be read.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    reason: Option<&'static str>,
79    /// What an invalid proof held, one line.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    detail: Option<String>,
82}
83
84/// What every setup proof code asks the operator to run, who holds the
85/// credentials the check reads.
86const 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    /// The violation a proof that is not compatible counts under
109    /// `--check`.
110    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
142/// The committed setup proof, for a target whose automatic release has a
143/// forge to set up; `None` for every other target, whose report is
144/// unchanged.
145fn 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
155/// The code a lapsed code scanning licence reports under.
156const CODE_SCANNING_LICENCE: &str = "code-scanning-licence";
157
158/// Where a target stands for the setup skill's gate: one word computed by
159/// the CLI's own rule, so no reader branches on five fields by hand.
160///
161/// SATISFIES landing:a-landing-classifies-its-target-first
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
163#[serde(rename_all = "kebab-case")]
164pub enum Classification {
165    /// No record, no release mechanism, and no release history.
166    Setup,
167    /// No record and another release mechanism in place.
168    Migration,
169    /// No record and release activity no mechanism explains.
170    NeedsDecision,
171    /// No record, and release-kit's own landing is present.
172    Adopt,
173    /// A record older than this binary.
174    Upgrade,
175    /// A record at this binary's version that `--check` fails.
176    Drift,
177    /// A record at this binary's version that `--check` passes.
178    Current,
179}
180
181impl Classification {
182    /// The kebab-case word, as the JSON serializes it.
183    #[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
197/// The classification of a target with no record, from the assessment's
198/// rule and the adoption detector.
199const 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
212/// The classification of a recorded target. A record newer than this
213/// binary has none: that is an observation the setup skill routes to
214/// provisioning, not a state this binary can judge.
215const 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/// One unfilled judgment sentinel, located.
225#[derive(Debug, Clone, Serialize)]
226struct SentinelAt {
227    /// The landed file that carries it.
228    file: String,
229    /// Its line, counted from one.
230    line: usize,
231    /// The line's text, trimmed.
232    text: String,
233}
234
235/// One path a 0.4.x release left under the state root.
236#[derive(Debug, Serialize)]
237struct LegacyPath {
238    /// The absolute path.
239    path: String,
240    /// `plan` for a stored plan, `release-cache` for the release cache.
241    kind: &'static str,
242    /// Whether it holds an operation the old binary could still apply.
243    active: bool,
244}
245
246/// The state a 0.4.x release stored under the state root, each path with
247/// whether an operation is active. No later release writes `plans/` or
248/// `release/`, so what is there is the old binary's.
249///
250/// A stored plan reads as active while its own document says an apply
251/// may run it, and while it cannot be read at all, because only the
252/// operator can retire an operation the old binary owns. The release
253/// cache holds no operation.
254fn 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
290/// Where a 0.4.x release stored its plans, under the state root.
291const LEGACY_PLANS: &str = "plans";
292
293/// Where a 0.4.x release cached fetched releases, under the state root.
294const LEGACY_RELEASE_CACHE: &str = "release";
295
296/// The status report's shape version.
297const SCHEMA: &str = "rk.status/15";
298
299/// Configuration is informational, independent of every drift comparison.
300#[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/// The machine form of a status report.
325#[derive(Debug, Serialize)]
326struct Report {
327    /// The shape version of this document.
328    schema: &'static str,
329    /// Whether a landing record exists; most other fields need one.
330    landed: bool,
331    /// Where the target stands for the setup skill's gate; null where the
332    /// record is newer than this binary.
333    classification: Option<Classification>,
334    /// Whether the target sits in a non-bare Git work tree.
335    git_work_tree: bool,
336    config: ConfigState,
337    /// What the record says the project is.
338    #[serde(skip_serializing_if = "Option::is_none")]
339    profile: Option<ProfileSnapshot>,
340    /// The recorded Git workflow.
341    #[serde(skip_serializing_if = "Option::is_none")]
342    git: Option<GitWorkflow>,
343    /// The recorded capability requests.
344    #[serde(skip_serializing_if = "Option::is_none")]
345    capabilities: Option<CapabilityRequests>,
346    /// Every capability the catalog answers for the recorded values, in
347    /// catalog order; absent where this binary cannot project the record.
348    #[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    /// Recorded destinations absent from the disk.
359    #[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    /// Unresolved judgment sentinels across the landed files.
364    #[serde(skip_serializing_if = "Option::is_none")]
365    sentinels: Option<usize>,
366    /// Each unresolved sentinel, by file and line.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    sentinel_locations: Option<Vec<SentinelAt>>,
369    /// Record-set disagreements between the recorded parameters'
370    /// projection and the recorded destinations — its own count, because
371    /// no file was edited and the kind counts must stay honest.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    record_drift: Option<usize>,
374    /// Invariants a landed file's effective configuration violates —
375    /// judged, never rewritten, because the file stays the target's.
376    #[serde(skip_serializing_if = "Option::is_none")]
377    invariant_failures: Option<Vec<InvariantFailure>>,
378    /// Conditions that are reportable and not violations: `--check` exits 0
379    /// on them, because the target is not broken and the decision is the
380    /// operator's.
381    #[serde(skip_serializing_if = "Option::is_none")]
382    warnings: Option<Vec<Warning>>,
383    /// Where the committed setup proof stands, on a target with an
384    /// automatic release and a forge; absent on every other target.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    setup_proof: Option<SetupProof>,
387    /// How many destinations an upgrade would change: the count this
388    /// binary's sources project under the recorded parameters against
389    /// what the record names. Zero means nothing to take, whatever the two
390    /// versions say. Absent on a landed target means this binary carries
391    /// no files for the recorded pair and cannot answer.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pending: Option<usize>,
394    /// Rendered files edited on purpose, each under its accepted-drift
395    /// entry and reason: reported, never a violation.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    accepted_drift: Option<Vec<AcceptedNote>>,
398    /// Accepted-drift entries whose file matches the receipt again: the
399    /// deviation is gone and the entry can go.
400    #[serde(skip_serializing_if = "Option::is_none")]
401    stale_accepted_drift: Option<Vec<String>>,
402    /// Present only under `--check`: what the judgment failed on.
403    #[serde(skip_serializing_if = "Option::is_none")]
404    violations: Option<Vec<String>>,
405    /// The state a 0.4.x release left under the state root.
406    legacy_state: Vec<LegacyPath>,
407    /// The open migration, where the configuration names one.
408    #[serde(skip_serializing_if = "Option::is_none")]
409    migration: Option<OpenMigration>,
410}
411
412/// An open migration, as the status reports it.
413#[derive(Debug, Serialize)]
414struct OpenMigration {
415    /// The identifier the configuration names.
416    id: String,
417    /// The workspace on this host, absent where this host holds none.
418    #[serde(skip_serializing_if = "Option::is_none")]
419    workspace: Option<String>,
420    /// How many sources are neither verified nor dropped, absent where
421    /// this host holds no registry.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    unfinished: Option<usize>,
424}
425
426/// The open migration the configuration names, read from this host's
427/// workspace where it holds one.
428///
429/// SATISFIES migration:the-configuration-names-the-open-migration
430fn open_migration(
431    target: &camino::Utf8Path,
432    config: Option<&crate::config::Config>,
433) -> Option<OpenMigration> {
434    let id = config.and_then(|config| config.migration.clone())?;
435    let workspace = crate::migrate::open_workspace(target).ok().flatten();
436    let unfinished = workspace
437        .as_ref()
438        .filter(|workspace| workspace.join(crate::migrate::REGISTRY_FILE).is_file())
439        .and_then(|workspace| crate::migrate::registry::load(workspace).ok())
440        .map(|registry| {
441            registry
442                .sources
443                .iter()
444                .filter(|source| !source.status.finished())
445                .count()
446        });
447    Some(OpenMigration {
448        id,
449        workspace: workspace.map(|workspace| workspace.to_string()),
450        unfinished,
451    })
452}
453
454/// The line a human report carries for an open migration.
455fn render_migration(out: Output, migration: Option<&OpenMigration>) {
456    if let Some(migration) = migration {
457        out.result_line(format!(
458            "migration {} is open; rk migrate status reads it",
459            migration.id
460        ));
461    }
462}
463
464fn report_absent(
465    out: Output,
466    args: &StatusArgs,
467    config: Option<&crate::config::Config>,
468    git_work_tree: bool,
469) -> Result<(), RkError> {
470    let migration = open_migration(&args.target, config);
471    let classification = match assess::standing(&args.target, config)? {
472        assess::Standing::Migrating => Classification::Migration,
473        assess::Standing::Unrecorded(class) => unrecorded(class),
474    };
475    let legacy = legacy_state();
476    out.result_line(format!("config: {}", config_state(config, None).state));
477    out.result_line(format!("no landing at {}", args.target));
478    out.result_line(format!("classification: {}", classification.as_str()));
479    render_migration(out, migration.as_ref());
480    render_context(out, git_work_tree, &legacy);
481    out.next(&[
482        format!(
483            "rk profile --target {} reports what a landing would select",
484            args.target
485        ),
486        format!("rk init --target {} lands the workflow", args.target),
487        format!(
488            "rk adopt --target {} records a landing made before the receipt existed",
489            args.target
490        ),
491        format!(
492            "rk stage --target {} stages this binary's candidate; the rk-setup skill carries the best-effort migration",
493            args.target
494        ),
495    ]);
496    out.emit(&Report {
497        schema: SCHEMA,
498        landed: false,
499        classification: Some(classification),
500        git_work_tree,
501        config: config_state(config, None),
502        profile: None,
503        git: None,
504        capabilities: None,
505        selection: None,
506        rk_version: None,
507        binary_version: None,
508        alignment: None,
509        drift: None,
510        missing: None,
511        stale_pins: None,
512        sentinels: None,
513        sentinel_locations: None,
514        record_drift: None,
515        invariant_failures: None,
516        warnings: None,
517        setup_proof: None,
518        pending: None,
519        accepted_drift: None,
520        stale_accepted_drift: None,
521        violations: args.check.then(|| vec!["no landing".to_owned()]),
522        legacy_state: legacy,
523        migration,
524    })?;
525    if args.check {
526        return Err(RkError::check_failed(
527            Diagnostic::new(
528                Reason::StateDrift,
529                format!("no landing at {}, and --check requires one", args.target),
530            )
531            .expected("a target carrying .release-kit/manifest.json")
532            .action("rk init lands the workflow; rk adopt records an existing landing; rk stage and the rk-setup skill carry the migration"),
533        ));
534    }
535    Ok(())
536}
537
538/// A rendered file under its accepted-drift entry.
539#[derive(Debug, Clone, Serialize)]
540struct AcceptedNote {
541    /// The destination.
542    destination: String,
543    /// Why the project deviates.
544    reason: String,
545}
546
547/// What one pass over the record and the disk observed.
548struct Observed {
549    drift_rendered: Vec<String>,
550    /// The current digest of each drifted rendered file, which the
551    /// remediation prints so an operator can accept it.
552    drift_digests: std::collections::BTreeMap<String, String>,
553    /// The reason of each drifted rendered file edited past its accepted
554    /// bytes, which the violation names.
555    drift_reasons: std::collections::BTreeMap<String, String>,
556    /// Rendered files whose bytes match their accepted-drift entry.
557    drift_accepted: Vec<AcceptedNote>,
558    /// Entries whose file matches the receipt again.
559    accepted_stale: Vec<String>,
560    drift_seeded: Vec<String>,
561    /// Recorded block destinations whose recorded digest the record's own
562    /// parameters do not reproduce: the record was edited, not the file.
563    parameter_drift: Vec<String>,
564    /// Set differences between what the recorded parameters project —
565    /// the withhold judgment applied — and the destinations the record
566    /// names: a record whose parameters and file list disagree, whichever
567    /// of the two was edited or outgrown.
568    record_drift: Vec<String>,
569    missing: Vec<String>,
570    stale: Vec<StalePin>,
571    sentinels: Vec<(String, usize, String)>,
572    invariants: Vec<InvariantFailure>,
573    /// Reportable conditions that are not violations.
574    warnings: Vec<Warning>,
575    setup_proof: Option<SetupProof>,
576    /// Recorded intents this target cannot honour, counted in
577    /// `record_drift` and kept separately because a plain upgrade cannot
578    /// repair them: it re-reads the same recorded answer and refuses. An
579    /// unavailable optional capability is not one of these; it is reported
580    /// in `selection` and omitted, and an upgrade runs over it unharmed.
581    incompatible: Vec<String>,
582    /// The destinations an upgrade would change, or `None` where this
583    /// binary carries no projection for the recorded pair and so cannot
584    /// say.
585    pending: Option<Vec<String>>,
586    /// Every capability the catalog answers for the recorded values.
587    selection: Option<Vec<CapabilityNote>>,
588}
589
590/// Report the target's landing.
591///
592/// # Errors
593///
594/// Returns [`RkError::Missing`] for a target that is not a directory, the
595/// record's own failure taxonomy for an unreadable or unknown record, and
596/// [`RkError::CheckFailed`] under `--check` when the report holds a
597/// violation.
598#[allow(
599    clippy::too_many_lines,
600    reason = "one pass reads the target, judges it, and emits the one report every reader takes"
601)]
602pub fn run(args: &StatusArgs) -> Result<(), RkError> {
603    let out = Output::new(args.json);
604    if !args.target.is_dir() {
605        return Err(RkError::missing(
606            Diagnostic::new(
607                Reason::TargetNotFound,
608                format!("target {} is not a directory", args.target),
609            )
610            .expected("an existing repository to report on"),
611        ));
612    }
613    let config = crate::config::load(args.target.as_std_path())?;
614    let git_work_tree = assess::git_work_tree(&args.target)?;
615    let Some(manifest) = manifest::load(&args.target)? else {
616        return report_absent(out, args, config.as_ref(), git_work_tree);
617    };
618
619    let migration = open_migration(&args.target, config.as_ref());
620    let accepted = config
621        .as_ref()
622        .map(|config| config.landing.accepted_drift.clone())
623        .unwrap_or_default();
624    let stated = Stated::of(config.as_ref(), &manifest);
625    let config = config_state(config.as_ref(), Some(&manifest));
626    out.result_line(format!("config: {}", config.state));
627    for key in &config.pending {
628        out.result_line(format!(
629            "config pending: {key}; rk upgrade --apply takes it up"
630        ));
631    }
632    let mut observed = observe(args, &manifest, &stated)?;
633    accept(&mut observed, &manifest, &accepted)?;
634    observed.setup_proof = setup_proof_of(&args.target, &manifest)?;
635    let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
636    render_human(out, args, &manifest, alignment, &observed);
637
638    let violations = violations_of(&observed);
639    // A record newer than this binary is no state it can judge, an open
640    // migration included; below that, the open migration outranks the
641    // record until it closes.
642    let classification = if migration.is_some() && alignment != Alignment::TargetNewer {
643        Some(Classification::Migration)
644    } else {
645        recorded(alignment, violations.is_empty())
646    };
647    out.result_line(format!(
648        "classification: {}",
649        classification.map_or(
650            "none: the record is newer than this binary",
651            Classification::as_str
652        )
653    ));
654    render_migration(out, migration.as_ref());
655    let legacy = legacy_state();
656    render_context(out, git_work_tree, &legacy);
657    out.emit(&Report {
658        schema: SCHEMA,
659        landed: true,
660        classification,
661        git_work_tree,
662        config,
663        profile: Some(manifest.profile.clone()),
664        git: Some(manifest.git.clone()),
665        capabilities: Some(manifest.capabilities.clone()),
666        selection: observed.selection.clone(),
667        rk_version: Some(manifest.rk_version),
668        binary_version: Some(env!("CARGO_PKG_VERSION")),
669        alignment: Some(alignment),
670        drift: Some(Drift {
671            rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
672            seeded: observed.drift_seeded.len(),
673        }),
674        record_drift: Some(observed.record_drift.len()),
675        missing: Some(observed.missing.clone()),
676        stale_pins: Some(observed.stale),
677        sentinels: Some(observed.sentinels.len()),
678        sentinel_locations: Some(
679            observed
680                .sentinels
681                .iter()
682                .map(|(file, line, text)| SentinelAt {
683                    file: file.clone(),
684                    line: *line,
685                    text: text.clone(),
686                })
687                .collect(),
688        ),
689        invariant_failures: Some(observed.invariants),
690        warnings: Some(observed.warnings),
691        setup_proof: observed.setup_proof.clone(),
692        pending: observed.pending.as_ref().map(Vec::len),
693        accepted_drift: Some(observed.drift_accepted.clone()),
694        stale_accepted_drift: Some(observed.accepted_stale.clone()),
695        violations: args.check.then(|| violations.clone()),
696        legacy_state: legacy,
697        migration,
698    })?;
699
700    if args.check && !violations.is_empty() {
701        return Err(RkError::check_failed(
702            Diagnostic::new(
703                Reason::StateDrift,
704                format!(
705                    "the landing is not clean: {} violation{}",
706                    violations.len(),
707                    if violations.len() == 1 { "" } else { "s" }
708                ),
709            )
710            .expected(
711                "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
712            ),
713        ));
714    }
715    Ok(())
716}
717
718/// The human lines every report carries, landed or not: whether the
719/// target is a work tree the method can land in, and each path a 0.4.x
720/// release left under the state root.
721fn render_context(out: Output, git_work_tree: bool, legacy: &[LegacyPath]) {
722    if !git_work_tree {
723        out.result_line("not a non-bare Git work tree: the method lands files a work tree commits");
724    }
725    for entry in legacy {
726        out.result_line(format!(
727            "LEGACY {} ({}, {})",
728            entry.path,
729            entry.kind,
730            if entry.active { "active" } else { "inactive" }
731        ));
732    }
733}
734
735/// Move each drifted rendered file whose bytes match its accepted-drift
736/// entry out of the violations, name the reason of one edited past it,
737/// and report an entry whose file matches the receipt again.
738///
739/// An entry must name a whole-file rendered destination the receipt
740/// records; any other refuses as configuration. Accepted drift exempts a
741/// file from the drift judgment alone: an invariant failure on it stays.
742///
743/// SATISFIES accepted-drift:an-accepted-drift-names-its-reason-and-its-bytes
744fn accept(
745    observed: &mut Observed,
746    manifest: &Manifest,
747    accepted: &std::collections::BTreeMap<String, crate::config::AcceptedDrift>,
748) -> Result<(), RkError> {
749    for (destination, entry) in accepted {
750        let Some(file) = manifest.file(destination) else {
751            return Err(crate::config::invalid(format!(
752                "landing.accepted_drift names {destination}, which the receipt does not record"
753            )));
754        };
755        if file.kind != Kind::Rendered || file.placement != manifest::Placement::Whole {
756            return Err(crate::config::invalid(format!(
757                "landing.accepted_drift names {destination}, which is not a whole rendered file: only such a file has bytes release-kit owns whole"
758            )));
759        }
760        if !observed.drift_rendered.contains(destination) {
761            if !observed.missing.contains(destination) {
762                observed.accepted_stale.push(destination.clone());
763            }
764            continue;
765        }
766        if observed.drift_digests.get(destination) == Some(&entry.sha256) {
767            observed.drift_rendered.retain(|path| path != destination);
768            observed.drift_accepted.push(AcceptedNote {
769                destination: destination.clone(),
770                reason: entry.reason.clone(),
771            });
772        } else {
773            observed
774                .drift_reasons
775                .insert(destination.clone(), entry.reason.clone());
776        }
777    }
778    Ok(())
779}
780
781/// The check-mode violation lines: rendered drift, missing recorded
782/// files, unresolved sentinels, and invariant failures — the closed set
783/// `landing:status-judges-only-under-check` names.
784fn violations_of(observed: &Observed) -> Vec<String> {
785    observed
786        .drift_rendered
787        .iter()
788        .map(|path| {
789            let digest = observed.drift_digests.get(path).map_or("", String::as_str);
790            observed.drift_reasons.get(path).map_or_else(
791                || format!("rendered drift: {path}; its sha256 is {digest}"),
792                |reason| {
793                    format!(
794                        "rendered drift: {path}, edited past its accepted drift ({reason}); its sha256 is now {digest}"
795                    )
796                },
797            )
798        })
799        .chain(
800            observed
801                .parameter_drift
802                .iter()
803                .map(|path| format!("parameter drift: {path}")),
804        )
805        .chain(
806            observed
807                .record_drift
808                .iter()
809                .map(|reason| format!("record drift: {reason}")),
810        )
811        .chain(
812            observed
813                .missing
814                .iter()
815                .map(|path| format!("missing: {path}")),
816        )
817        .chain(
818            observed
819                .sentinels
820                .iter()
821                .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
822        )
823        .chain(
824            observed
825                .invariants
826                .iter()
827                .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
828        )
829        .chain(
830            observed
831                .setup_proof
832                .as_ref()
833                .and_then(SetupProof::failure)
834                .map(|failure| format!("setup proof: {}", failure.code)),
835        )
836        .collect()
837}
838
839/// The answers the target states now, for the judgments: a committed
840/// answer the landing verbs have yet to take up outranks the record.
841struct Stated {
842    /// Where the crate publishes.
843    registry: Option<RegistryAnswer>,
844    /// The check context of the gate.
845    required_check: String,
846}
847
848impl Stated {
849    fn of(config: Option<&crate::config::Config>, manifest: &Manifest) -> Self {
850        Self {
851            registry: config
852                .and_then(|config| config.profile.release.registry)
853                .or(manifest.profile.release.registry),
854            // The landing writes the check back as a class P answer, so a
855            // configuration that exists states it, an empty one included.
856            required_check: config.map_or_else(
857                || manifest.parameters.required_check.clone(),
858                |config| config.setup.required_check.clone(),
859            ),
860        }
861    }
862}
863
864/// One pass over the record and the disk: drift, missing files, stale
865/// pins, and sentinels.
866#[allow(
867    clippy::too_many_lines,
868    reason = "one pass over the record and the disk answers every comparison the report states"
869)]
870fn observe(args: &StatusArgs, manifest: &Manifest, stated: &Stated) -> Result<Observed, RkError> {
871    let mut observed = Observed {
872        drift_rendered: Vec::new(),
873        drift_digests: std::collections::BTreeMap::new(),
874        drift_reasons: std::collections::BTreeMap::new(),
875        drift_accepted: Vec::new(),
876        accepted_stale: Vec::new(),
877        drift_seeded: Vec::new(),
878        parameter_drift: Vec::new(),
879        record_drift: Vec::new(),
880        missing: Vec::new(),
881        stale: Vec::new(),
882        sentinels: Vec::new(),
883        invariants: Vec::new(),
884        warnings: Vec::new(),
885        setup_proof: None,
886        incompatible: Vec::new(),
887        pending: None,
888        selection: None,
889    };
890    // One projection serves every reader below, because each asks what
891    // this binary makes of the recorded parameters at this target. A pair
892    // this binary does not carry cannot be projected at all: under a
893    // receipt this binary wrote that is a defect and still fails, and
894    // under a landing from another rk it is a fact to report.
895    let aligned =
896        manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION")) == Alignment::Aligned;
897    let projected = match project(args, manifest) {
898        Ok(projection) => Some(projection),
899        Err(err) if aligned => return Err(err),
900        Err(_) => None,
901    };
902    for file in &manifest.files {
903        let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
904            observed.missing.push(file.destination.clone());
905            continue;
906        };
907        if Digest::of(&bytes) != file.sha256 {
908            match file.kind {
909                Kind::Rendered => {
910                    observed.drift_rendered.push(file.destination.clone());
911                    observed
912                        .drift_digests
913                        .insert(file.destination.clone(), Digest::of(&bytes).to_string());
914                }
915                Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
916                Kind::State => {}
917            }
918        }
919        observed.invariants.extend(invariants::failures(
920            manifest.profile.release.driver.as_deref().unwrap_or(""),
921            manifest.profile.forge.as_deref().unwrap_or(""),
922            &file.destination,
923            &bytes,
924            stated.registry,
925        ));
926        let text = String::from_utf8_lossy(&bytes);
927        for (idx, line) in text.lines().enumerate() {
928            if line.contains(embedded::SENTINEL) {
929                observed.sentinels.push((
930                    file.destination.clone(),
931                    idx + 1,
932                    line.trim().to_owned(),
933                ));
934            }
935        }
936        // A marked document's markers must be well formed even when its
937        // first block matches the receipt: a duplicate hook block still
938        // executes, so an ill-formed document reads as rendered drift,
939        // never as clean.
940        let defective = projected.as_ref().is_some_and(|projection| {
941            projection
942                .collisions
943                .iter()
944                .any(|collision| collision.destination == file.destination)
945        });
946        if defective && !observed.drift_rendered.contains(&file.destination) {
947            observed.drift_rendered.push(file.destination.clone());
948        }
949    }
950    // The cross-file step: a landed file can generate the artifact the
951    // forge actually executes, and the seeds ship no copy of it, so no
952    // recorded digest sees the two disagree. The pair's own rule reads
953    // both off the target's disk.
954    observed.invariants.extend(invariants::target_failures(
955        manifest.profile.release.driver.as_deref().unwrap_or(""),
956        manifest.profile.forge.as_deref().unwrap_or(""),
957        &args.target,
958    ));
959    // The gate builds every dist target before a merge, or the build
960    // fails only after the tag exists.
961    if manifest.profile.release.driver.as_deref() == Some("rust")
962        && manifest.profile.forge.as_deref() == Some("github")
963    {
964        let dev_run = manifest
965            .parameters
966            .dev_environment
967            .as_ref()
968            .map_or("", |answer| answer.run.as_str());
969        let gate = invariants::dist_targets_built(&args.target, &stated.required_check, dev_run);
970        observed.invariants.extend(gate.failures);
971        observed.warnings.extend(
972            gate.limitations
973                .into_iter()
974                .map(|(code, reason)| Warning { code, reason }),
975        );
976    }
977    observed.selection = projected.as_ref().map(|projection| {
978        projection
979            .capabilities
980            .iter()
981            .map(|selection| CapabilityNote::of(selection, projection))
982            .collect()
983    });
984    if let Some(reason) = projected
985        .as_ref()
986        .and_then(|projection| projection.licence_refusal.clone())
987    {
988        observed.warnings.push(Warning {
989            code: CODE_SCANNING_LICENCE,
990            reason,
991        });
992    }
993    // A receipt naming a release intent nothing can honour is judged at
994    // every alignment rather than only where this binary wrote the record.
995    // An unavailable optional capability is not one of these: it reports
996    // through `selection` and lands nothing, and no verb refuses on it.
997    if let Some(projection) = projected.as_ref() {
998        observed.incompatible.clone_from(&projection.record_defects);
999        observed
1000            .record_drift
1001            .extend(projection.record_defects.iter().cloned());
1002    }
1003    if aligned && let Some(projection) = projected.as_ref() {
1004        observe_parameter_drift(manifest, projection, &mut observed);
1005        observe_record_set(manifest, projection, &mut observed.record_drift);
1006    }
1007    // What an upgrade would change, which is the only honest ground for
1008    // telling an operator to run one. The recorded version says who wrote
1009    // the receipt, and two releases apart can carry identical bytes for
1010    // this pair, so it answers a different question and prompts nothing.
1011    observed.pending = projected
1012        .as_ref()
1013        .map(|projection| pending_of(manifest, &projection.candidates));
1014    // Stale means behind, not merely different: a landing from a newer rk
1015    // can carry pins ahead of this binary's registry, and that is the
1016    // alignment line's story, not a freshness complaint.
1017    for (tool, landed) in &manifest.pins {
1018        if let Some(available) = registry::version_of(tool)
1019            && manifest::version_is_newer(&available, landed)
1020        {
1021            observed.stale.push(StalePin {
1022                tool: tool.clone(),
1023                landed: landed.clone(),
1024                available,
1025            });
1026        }
1027    }
1028    Ok(observed)
1029}
1030
1031/// The receipt-consistency step over every rendered destination.
1032///
1033/// Recorded digests alone cannot see a receipt edited only at its
1034/// parameters, since every file still matches its own record, so every
1035/// rendered candidate, whole file or region, as this projection renders
1036/// it from the receipt's own parameters, is compared against the digest
1037/// the receipt stores for it. Called only where this binary wrote the
1038/// receipt: an older landing's files legitimately differ from this
1039/// projection, which is the alignment line's story and the upgrade's job,
1040/// not parameter drift. A destination already reported as rendered drift
1041/// is the file's own story, not the receipt's, and is skipped too.
1042fn observe_parameter_drift(manifest: &Manifest, projection: &Projection, observed: &mut Observed) {
1043    for candidate in &projection.candidates {
1044        if candidate.kind != Kind::Rendered {
1045            continue;
1046        }
1047        let Some(record) = manifest.file(&candidate.destination) else {
1048            continue;
1049        };
1050        if observed.drift_rendered.contains(&candidate.destination)
1051            || observed.missing.contains(&candidate.destination)
1052        {
1053            continue;
1054        }
1055        if Digest::of(recorded_form(candidate)) != record.sha256 {
1056            observed
1057                .parameter_drift
1058                .push(format!("{} (parameters)", candidate.destination));
1059        }
1060    }
1061}
1062
1063/// What this binary projects under the receipt's own parameters at this
1064/// target, with the same withhold judgment a landing applies, so the
1065/// comparison stands against what an upgrade would actually offer.
1066fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Projection, RkError> {
1067    let evidence = TargetEvidence::gather(&args.target, Some(manifest))?;
1068    Projection::compute(&ProjectionInput {
1069        params: landing::Params::from_record(manifest),
1070        evidence,
1071    })
1072}
1073
1074/// The bytes the receipt digests for a candidate: the whole file, or the
1075/// marked region alone.
1076fn recorded_form(candidate: &Candidate) -> &[u8] {
1077    candidate.region.as_deref().unwrap_or(&candidate.bytes)
1078}
1079
1080/// The destinations an upgrade would change, read off the record alone.
1081///
1082/// A destination the projection adds or drops changes the record either
1083/// way, and a `rendered` one whose candidate digest differs from the
1084/// recorded digest is rewritten. A `seeded` or `state` destination the
1085/// record already names is never rewritten, so only a change of kind
1086/// counts for it. Disk drift is a separate story, told by its own lines:
1087/// an edited file is the target's doing, not a newer binary's.
1088fn pending_of(manifest: &Manifest, projected: &[Candidate]) -> Vec<String> {
1089    let mut pending = Vec::new();
1090    for entry in projected {
1091        let changed = manifest.file(&entry.destination).is_none_or(|record| {
1092            record.kind != entry.kind
1093                || (entry.kind == Kind::Rendered
1094                    && record.sha256 != Digest::of(recorded_form(entry)))
1095        });
1096        if changed {
1097            pending.push(entry.destination.clone());
1098        }
1099    }
1100    for file in &manifest.files {
1101        if !projected
1102            .iter()
1103            .any(|entry| entry.destination == file.destination)
1104        {
1105            pending.push(file.destination.clone());
1106        }
1107    }
1108    pending.sort();
1109    pending.dedup();
1110    pending
1111}
1112
1113/// The receipt-set consistency step: the recorded digests judge each
1114/// named file, and the region re-render judges the region records, but
1115/// neither can see a receipt whose parameters and file list disagree, a
1116/// nix flag flipped in the receipt with no file landed, or a
1117/// once-withheld capability whose target grew into the supported shape.
1118/// So the projection is computed from the receipt's own parameters, the
1119/// same withhold judgment applied, and the two destination sets compared
1120/// both ways. A destination the projection withholds at this target is
1121/// absent because withheld, which is not drift. Called only where this
1122/// binary wrote the receipt: an older landing's set legitimately differs,
1123/// and that is the alignment line's story.
1124fn observe_record_set(
1125    manifest: &Manifest,
1126    projection: &Projection,
1127    record_drift: &mut Vec<String>,
1128) {
1129    for entry in &projection.candidates {
1130        if manifest.file(&entry.destination).is_none() {
1131            record_drift.push(format!(
1132                "the recorded parameters project {}, which the receipt does not name",
1133                entry.destination
1134            ));
1135        }
1136    }
1137    for file in &manifest.files {
1138        let produced = projection
1139            .candidates
1140            .iter()
1141            .any(|entry| entry.destination == file.destination)
1142            || projection
1143                .omissions
1144                .iter()
1145                .any(|omission| omission.destination == file.destination);
1146        if !produced {
1147            record_drift.push(format!(
1148                "the receipt names {}, which the recorded parameters do not project",
1149                file.destination
1150            ));
1151        }
1152    }
1153}
1154
1155/// The human lines, identical with and without `--check`.
1156#[allow(
1157    clippy::too_many_lines,
1158    reason = "one pass prints every reportable condition in the order the report states them"
1159)]
1160fn render_human(
1161    out: Output,
1162    args: &StatusArgs,
1163    manifest: &Manifest,
1164    alignment: Alignment,
1165    observed: &Observed,
1166) {
1167    out.result_line(format!(
1168        "release-kit {} at {}: {}",
1169        manifest.rk_version,
1170        args.target,
1171        crate::commands::profile::describe(
1172            &manifest.profile,
1173            &manifest.git,
1174            &manifest.capabilities,
1175            &manifest.parameters.repo
1176        )
1177    ));
1178    // Every capability the record does not select is information, never
1179    // a violation: a product nobody asked for, or one this target's
1180    // dimensions cannot take.
1181    for note in observed.selection.iter().flatten() {
1182        if note.status != "selected" {
1183            out.result_line(format!(
1184                "capability {}: {}{}",
1185                note.id,
1186                note.status,
1187                note.reason
1188                    .as_deref()
1189                    .map_or_else(String::new, |reason| format!(" ({reason})"))
1190            ));
1191        }
1192    }
1193    if alignment == Alignment::TargetNewer {
1194        out.result_line(format!(
1195            "binary {} is older than this landing; install the matching rk",
1196            env!("CARGO_PKG_VERSION")
1197        ));
1198    }
1199    match observed.pending.as_deref() {
1200        None => out.result_line(format!(
1201            "this binary carries no projection for the recorded {} release on {}, so what an upgrade would change is unknown",
1202            manifest
1203                .profile
1204                .release
1205                .driver
1206                .as_deref()
1207                .unwrap_or("release-less"),
1208            manifest.profile.forge.as_deref().unwrap_or("no forge")
1209        )),
1210        Some(paths) => {
1211            for path in paths {
1212                out.result_line(format!("PENDING {path} (this binary would change it)"));
1213            }
1214        }
1215    }
1216    for path in &observed.drift_rendered {
1217        let digest = observed.drift_digests.get(path).map_or("", String::as_str);
1218        out.result_line(observed.drift_reasons.get(path).map_or_else(
1219            || {
1220                format!(
1221                    "DRIFT {path} (rendered, release-kit-owned): sha256 {digest}; a deliberate edit is accepted under landing.accepted_drift with its reason and this digest"
1222                )
1223            },
1224            |reason| {
1225                format!(
1226                    "DRIFT {path} (rendered, edited past its accepted drift: {reason}; sha256 now {digest})"
1227                )
1228            },
1229        ));
1230    }
1231    for note in &observed.drift_accepted {
1232        out.result_line(format!(
1233            "ACCEPTED {} (rendered, accepted drift: {})",
1234            note.destination, note.reason
1235        ));
1236    }
1237    for path in &observed.accepted_stale {
1238        out.result_line(format!(
1239            "STALE landing.accepted_drift entry for {path}: the file matches the receipt, so the entry can go"
1240        ));
1241    }
1242    for path in &observed.parameter_drift {
1243        out.result_line(format!(
1244            "DRIFT {path}: the recorded parameters do not render the recorded bytes"
1245        ));
1246    }
1247    for reason in &observed.record_drift {
1248        out.result_line(format!("DRIFT record: {reason}"));
1249    }
1250    for path in &observed.drift_seeded {
1251        out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
1252    }
1253    for path in &observed.missing {
1254        out.result_line(format!("MISSING {path}"));
1255    }
1256    for pin in &observed.stale {
1257        out.result_line(format!(
1258            "STALE {} {} landed, {} in this binary",
1259            pin.tool, pin.landed, pin.available
1260        ));
1261    }
1262    for (path, line, text) in &observed.sentinels {
1263        out.result_line(format!("SENTINEL {path}:{line}: {text}"));
1264    }
1265    for failure in &observed.invariants {
1266        out.result_line(format!(
1267            "INVARIANT {} ({}): {}",
1268            failure.destination, failure.code, failure.reason
1269        ));
1270    }
1271    for warning in &observed.warnings {
1272        out.result_line(format!("WARNING ({}): {}", warning.code, warning.reason));
1273    }
1274    let proof_failure = observed.setup_proof.as_ref().and_then(SetupProof::failure);
1275    if let Some(proof) = &observed.setup_proof {
1276        out.result_line(format!("setup proof: {}", proof.state));
1277    }
1278    if let Some(failure) = &proof_failure {
1279        out.result_line(format!(
1280            "SETUP-PROOF ({}): {}",
1281            failure.code, failure.reason
1282        ));
1283    }
1284    let mut next = Vec::new();
1285    for failure in observed.invariants.iter().chain(&proof_failure) {
1286        next.push(format!("{}: {}", failure.destination, failure.remediation));
1287    }
1288    // The incompatible ones first, and with the override: a plain upgrade
1289    // reads the same recorded intent and refuses, so advertising it alone
1290    // would send the operator into a loop.
1291    if !observed.incompatible.is_empty() {
1292        next.push(format!(
1293            "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",
1294            args.target
1295        ));
1296    }
1297    if observed.record_drift.len() > observed.incompatible.len() {
1298        next.push(format!(
1299            "rk upgrade --target {} rewrites the receipt from its parameters",
1300            args.target
1301        ));
1302    }
1303    if observed
1304        .pending
1305        .as_deref()
1306        .is_none_or(|paths| !paths.is_empty())
1307    {
1308        next.push(format!(
1309            "rk upgrade --target {} takes this landing to {}",
1310            args.target,
1311            env!("CARGO_PKG_VERSION")
1312        ));
1313    }
1314    next.push(format!(
1315        "rk status --check --target {} exits 1 on a violation",
1316        args.target
1317    ));
1318    if manifest.profile.release.mode != ReleaseMode::Automatic {
1319        next.retain(|line| !line.contains("rk method operate"));
1320    }
1321    out.next(&next);
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326    use super::{
1327        Classification, Drift, InvariantFailure, LegacyPath, OpenMigration, Report, SentinelAt,
1328        StalePin,
1329    };
1330    use crate::assess::{self, Facts, OwnLanding, Unrecorded};
1331    use crate::landing::CheckoutMode;
1332    use crate::landing::Integration;
1333    use crate::landing::manifest::Alignment;
1334    use crate::profile::{
1335        CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
1336    };
1337
1338    /// Accepted drift exempts a file from the drift judgment alone: an
1339    /// invariant failure on the same file stays a violation.
1340    #[test]
1341    fn accepted_drift_does_not_silence_an_invariant() {
1342        let destination = ".gitlab-ci.yml".to_owned();
1343        let digest = "a".repeat(64);
1344        let mut observed = super::Observed {
1345            drift_rendered: vec![destination.clone()],
1346            drift_digests: std::iter::once((destination.clone(), digest.clone())).collect(),
1347            drift_reasons: std::collections::BTreeMap::new(),
1348            drift_accepted: Vec::new(),
1349            accepted_stale: Vec::new(),
1350            drift_seeded: Vec::new(),
1351            parameter_drift: Vec::new(),
1352            record_drift: Vec::new(),
1353            missing: Vec::new(),
1354            stale: Vec::new(),
1355            sentinels: Vec::new(),
1356            invariants: vec![crate::landing::invariants::InvariantFailure {
1357                code: "an-invariant",
1358                destination: destination.clone(),
1359                reason: "the accepted bytes break it".to_owned(),
1360                remediation: "restore it",
1361            }],
1362            warnings: Vec::new(),
1363            setup_proof: None,
1364            incompatible: Vec::new(),
1365            pending: None,
1366            selection: None,
1367        };
1368        let manifest: crate::landing::manifest::Manifest = serde_json::from_str(&format!(
1369            r#"{{"schema_version":12,"rk_version":"0.1.0","origin":"init","landed_at":"2026-08-29T00:00:00Z","profile":{{"technologies":["bash"],"forge":"gitlab","release":{{"mode":"automatic","driver":"bash","style":"trunk","line_prefix":"release/"}}}},"git":{{"trunk":"master","checkout_mode":"linked-worktree","integration":"local","merge_form":"squash"}},"capabilities":{{"nix_packaging":false,"reporting_policy":true,"scorecard":false,"workflow_guards":false}},"parameters":{{"repo":"acme/widget"}},"files":[{{"destination":".gitlab-ci.yml","kind":"rendered","sha256":"{}"}}],"pins":{{}}}}"#,
1370            crate::digest::Digest::of(b"candidate")
1371        ))
1372        .expect("the record parses");
1373        let accepted = std::iter::once((
1374            destination,
1375            crate::config::AcceptedDrift {
1376                reason: "deliberate".to_owned(),
1377                sha256: digest,
1378            },
1379        ))
1380        .collect();
1381        super::accept(&mut observed, &manifest, &accepted).expect("accepts");
1382        assert!(observed.drift_rendered.is_empty());
1383        let violations = super::violations_of(&observed);
1384        assert!(
1385            violations.iter().any(|line| line.contains("an-invariant")),
1386            "{violations:?}"
1387        );
1388    }
1389
1390    /// The complete `rk.status/15` shape, held by snapshot in both the
1391    /// landed and absent forms.
1392    #[test]
1393    #[allow(
1394        clippy::too_many_lines,
1395        reason = "the snapshot states every field of both forms in one place"
1396    )]
1397    fn the_status_report_schema_snapshot_holds() {
1398        let landed = Report {
1399            schema: super::SCHEMA,
1400            landed: true,
1401            classification: Some(Classification::Upgrade),
1402            git_work_tree: true,
1403            config: super::ConfigState {
1404                state: "pending",
1405                pending: vec!["profile.release.style".into()],
1406            },
1407            profile: Some(ProfileSnapshot {
1408                technologies: vec!["rust".into()],
1409                forge: Some("github".into()),
1410                release: ReleaseIntent {
1411                    mode: ReleaseMode::Automatic,
1412                    driver: Some("rust".into()),
1413                    style: Some(crate::landing::Style::Trunk),
1414                    line_prefix: Some("release/".into()),
1415                    registry: None,
1416                    build: None,
1417                },
1418            }),
1419            git: Some(GitWorkflow {
1420                trunk: "master".into(),
1421                checkout_mode: CheckoutMode::LinkedWorktree,
1422                integration: Integration::Local,
1423                merge_form: crate::landing::MergeForm::Squash,
1424            }),
1425            capabilities: Some(CapabilityRequests {
1426                nix_packaging: true,
1427                reporting_policy: true,
1428                scorecard: false,
1429                code_scanning: Some(crate::landing::Provider::Semgrep),
1430                workflow_guards: Some(false),
1431            }),
1432            selection: Some(vec![]),
1433            rk_version: Some("0.1.0".into()),
1434            binary_version: Some("0.2.0"),
1435            alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
1436            drift: Some(Drift {
1437                rendered: 0,
1438                seeded: 1,
1439            }),
1440            missing: Some(vec![]),
1441            stale_pins: Some(vec![StalePin {
1442                tool: "release-plz".into(),
1443                landed: "0.3.160".into(),
1444                available: "0.3.170".into(),
1445            }]),
1446            sentinels: Some(1),
1447            sentinel_locations: Some(vec![SentinelAt {
1448                file: "release-plz.toml".into(),
1449                line: 18,
1450                text: "# TODO(release-kit): keep false for a binary-only crate".into(),
1451            }]),
1452            record_drift: Some(0),
1453            invariant_failures: Some(vec![InvariantFailure {
1454                code: "attestations-disabled",
1455                destination: "dist-workspace.toml".into(),
1456                reason: "github-attestations is not effectively true".into(),
1457                remediation: "set github-attestations = true in [dist]",
1458            }]),
1459            warnings: Some(vec![super::Warning {
1460                code: super::CODE_SCANNING_LICENCE,
1461                reason: "the target's license, LicenseRef-proprietary, does not state a codebase this release recognizes as open source".into(),
1462            }]),
1463            setup_proof: Some(super::SetupProof {
1464                state: "stale",
1465                changed: vec!["target.required_check".into()],
1466                reason: None,
1467                detail: None,
1468            }),
1469            pending: Some(2),
1470            accepted_drift: Some(vec![super::AcceptedNote {
1471                destination: ".gitlab-ci.yml".into(),
1472                reason: "the image carries a tool".into(),
1473            }]),
1474            stale_accepted_drift: Some(vec![]),
1475            violations: None,
1476            legacy_state: vec![LegacyPath {
1477                path: "/state/release-kit/plans/0123456789abcdef".into(),
1478                kind: "plan",
1479                active: true,
1480            }],
1481            migration: Some(OpenMigration {
1482                id: "20261009T140205Z".into(),
1483                workspace: Some(
1484                    "/state/release-kit/migrations/widget-0123456789ab/20261009T140205Z".into(),
1485                ),
1486                unfinished: Some(2),
1487            }),
1488        };
1489        assert_eq!(
1490            serde_json::to_string(&landed).expect("a report serializes"),
1491            r##"{"schema":"rk.status/15","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,"accepted_drift":[{"destination":".gitlab-ci.yml","reason":"the image carries a tool"}],"stale_accepted_drift":[],"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}}"##
1492        );
1493        let absent = Report {
1494            landed: false,
1495            classification: Some(Classification::Setup),
1496            git_work_tree: false,
1497            config: super::ConfigState {
1498                state: "absent",
1499                pending: vec![],
1500            },
1501            profile: None,
1502            git: None,
1503            capabilities: None,
1504            selection: None,
1505            rk_version: None,
1506            binary_version: None,
1507            alignment: None,
1508            drift: None,
1509            missing: None,
1510            stale_pins: None,
1511            sentinels: None,
1512            sentinel_locations: None,
1513            record_drift: None,
1514            invariant_failures: None,
1515            warnings: None,
1516            setup_proof: None,
1517            pending: None,
1518            accepted_drift: None,
1519            stale_accepted_drift: None,
1520            violations: None,
1521            legacy_state: vec![],
1522            migration: None,
1523            ..landed
1524        };
1525        assert_eq!(
1526            serde_json::to_string(&absent).expect("a report serializes"),
1527            r#"{"schema":"rk.status/15","landed":false,"classification":"setup","git_work_tree":false,"config":{"state":"absent","pending":[]},"legacy_state":[]}"#,
1528            "an absent landing reports the fields a caller can branch on"
1529        );
1530        let newer = Report {
1531            classification: None,
1532            ..absent
1533        };
1534        assert!(
1535            serde_json::to_string(&newer)
1536                .expect("a report serializes")
1537                .contains(r#""classification":null"#),
1538            "no classification is stated as null, never omitted"
1539        );
1540    }
1541
1542    fn facts() -> Facts {
1543        Facts {
1544            technologies: Vec::new(),
1545            forge: None,
1546            repo: None,
1547            release_markers: Vec::new(),
1548            collisions: Vec::new(),
1549            git: true,
1550            tags: 0,
1551            long_lived_branches: Vec::new(),
1552        }
1553    }
1554
1555    /// Each value the gate reads has one rule that earns it: the
1556    /// assessment's three for a target with no record, the adoption
1557    /// detector before them, and the alignment and the check for a
1558    /// recorded one.
1559    #[test]
1560    fn every_classification_has_its_rule() {
1561        let none = OwnLanding::default();
1562        let classify = |facts: &Facts, own: &OwnLanding| {
1563            super::unrecorded(assess::classify_unrecorded(facts, own))
1564        };
1565        assert_eq!(classify(&facts(), &none), Classification::Setup);
1566        let marked = Facts {
1567            release_markers: vec![".goreleaser.yaml".into()],
1568            ..facts()
1569        };
1570        assert_eq!(classify(&marked, &none), Classification::Migration);
1571        let tagged = Facts { tags: 3, ..facts() };
1572        assert_eq!(classify(&tagged, &none), Classification::NeedsDecision);
1573        // release-kit's own regions read as collisions to the assessment,
1574        // and the detector outranks them.
1575        let own = OwnLanding {
1576            regions: vec!["AGENTS.md".into()],
1577            rendered: Vec::new(),
1578        };
1579        let colliding = Facts {
1580            collisions: vec!["AGENTS.md".into()],
1581            release_markers: vec!["CHANGELOG.md".into()],
1582            ..facts()
1583        };
1584        assert_eq!(classify(&colliding, &own), Classification::Adopt);
1585        assert_eq!(
1586            assess::classify_unrecorded(&colliding, &own),
1587            Unrecorded::Adopt
1588        );
1589        assert_eq!(
1590            super::recorded(Alignment::BinaryNewer, false),
1591            Some(Classification::Upgrade)
1592        );
1593        assert_eq!(
1594            super::recorded(Alignment::Aligned, false),
1595            Some(Classification::Drift)
1596        );
1597        assert_eq!(
1598            super::recorded(Alignment::Aligned, true),
1599            Some(Classification::Current)
1600        );
1601        assert_eq!(super::recorded(Alignment::TargetNewer, true), None);
1602        for (class, word) in [
1603            (Classification::Setup, "setup"),
1604            (Classification::Migration, "migration"),
1605            (Classification::NeedsDecision, "needs-decision"),
1606            (Classification::Adopt, "adopt"),
1607            (Classification::Upgrade, "upgrade"),
1608            (Classification::Drift, "drift"),
1609            (Classification::Current, "current"),
1610        ] {
1611            assert_eq!(class.as_str(), word);
1612            assert_eq!(
1613                serde_json::to_string(&class).expect("serializes"),
1614                format!("\"{word}\"")
1615            );
1616        }
1617    }
1618}