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