Skip to main content

release_kit/commands/
status.rs

1//! `rk status`: a target describes itself from its own disk.
2//!
3//! Read-only and offline: the receipt supplies what landed, this binary's
4//! projection supplies what an upgrade would offer, the embedded registry
5//! supplies the pin comparison, and no network is ever touched: a fetch
6//! is a way for a status command to hang, fail on a network it should not
7//! need, or leak a repository's existence. Status compares the current
8//! target with this binary's projection and the receipt and nothing else;
9//! it reads no stage and resolves no other release. Plain `rk status`
10//! reports and exits 0 for every reportable state, drift and no-landing
11//! included; `--check` computes the identical report and changes only
12//! the final judgment, the one sanctioned bare exit 1.
13//!
14//! SATISFIES landing:status-judges-only-under-check
15
16use serde::Serialize;
17
18use crate::cli::status::StatusArgs;
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::digest::Digest;
21use crate::error::RkError;
22use crate::landing::invariants::{self, InvariantFailure};
23use crate::landing::manifest::{self, Alignment, Manifest};
24use crate::landing::{self, Kind};
25use crate::output::Output;
26use crate::profile::{CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseMode};
27use crate::projection::{Candidate, Projection, ProjectionInput, TargetEvidence};
28use crate::stage::CapabilityNote;
29use crate::{embedded, registry};
30
31/// Drift counts by owned kind; `state` files are never compared.
32#[derive(Debug, Serialize)]
33struct Drift {
34    /// Edits to files release-kit owns — the violation class.
35    rendered: usize,
36    /// Edits to files the target owns — expected and informational.
37    seeded: usize,
38}
39
40/// One recorded pin that is behind this binary's registry.
41#[derive(Debug, Serialize)]
42struct StalePin {
43    /// The tool's registry name.
44    tool: String,
45    /// The version the landing recorded.
46    landed: String,
47    /// The version this binary's registry pins.
48    available: String,
49}
50
51/// One reportable condition that is not a violation: the target is not
52/// broken and the decision behind it is the operator's.
53///
54/// The `code` is stable, so a machine reader branches on it rather than on
55/// the prose.
56#[derive(Debug, Serialize)]
57struct Warning {
58    /// The stable reason code.
59    code: &'static str,
60    /// What the condition is, in the target's own terms.
61    reason: String,
62}
63
64/// The code a lapsed code scanning licence reports under.
65const CODE_SCANNING_LICENCE: &str = "code-scanning-licence";
66
67/// Configuration is informational, independent of every drift comparison.
68#[derive(Debug, serde::Serialize)]
69struct ConfigState {
70    state: &'static str,
71    pending: Vec<String>,
72}
73
74fn config_state(config: Option<&crate::config::Config>, record: Option<&Manifest>) -> ConfigState {
75    let pending = config
76        .zip(record)
77        .map_or_else(Vec::new, |(config, record)| {
78            crate::config::pending(config, record)
79        });
80    ConfigState {
81        state: if config.is_none() {
82            "absent"
83        } else if pending.is_empty() {
84            "aligned"
85        } else {
86            "pending"
87        },
88        pending,
89    }
90}
91
92/// The machine form of a status report.
93#[derive(Debug, Serialize)]
94struct Report {
95    /// The shape version of this document.
96    schema: &'static str,
97    /// Whether a landing record exists; every other field needs one.
98    landed: bool,
99    config: ConfigState,
100    /// What the record says the project is.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    profile: Option<ProfileSnapshot>,
103    /// The recorded Git workflow.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    git: Option<GitWorkflow>,
106    /// The recorded capability requests.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    capabilities: Option<CapabilityRequests>,
109    /// Every capability the catalog answers for the recorded values, in
110    /// catalog order; absent where this binary cannot project the record.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    selection: Option<Vec<CapabilityNote>>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    rk_version: Option<String>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    binary_version: Option<&'static str>,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    alignment: Option<Alignment>,
119    #[serde(skip_serializing_if = "Option::is_none")]
120    drift: Option<Drift>,
121    /// Recorded destinations absent from the disk.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    missing: Option<Vec<String>>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    stale_pins: Option<Vec<StalePin>>,
126    /// Unresolved judgment sentinels across the landed files.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    sentinels: Option<usize>,
129    /// Record-set disagreements between the recorded parameters'
130    /// projection and the recorded destinations — its own count, because
131    /// no file was edited and the kind counts must stay honest.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    record_drift: Option<usize>,
134    /// Invariants a landed file's effective configuration violates —
135    /// judged, never rewritten, because the file stays the target's.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    invariant_failures: Option<Vec<InvariantFailure>>,
138    /// Conditions that are reportable and not violations: `--check` exits 0
139    /// on them, because the target is not broken and the decision is the
140    /// operator's.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    warnings: Option<Vec<Warning>>,
143    /// How many destinations an upgrade would change: the count this
144    /// binary's sources project under the recorded parameters against
145    /// what the record names. Zero means nothing to take, whatever the two
146    /// versions say. Absent on a landed target means this binary carries
147    /// no files for the recorded pair and cannot answer.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pending: Option<usize>,
150    /// Present only under `--check`: what the judgment failed on.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    violations: Option<Vec<String>>,
153}
154
155fn report_absent(
156    out: Output,
157    args: &StatusArgs,
158    config: Option<&crate::config::Config>,
159) -> Result<(), RkError> {
160    out.result_line(format!("config: {}", config_state(config, None).state));
161    out.result_line(format!("no landing at {}", args.target));
162    out.next(&[
163        format!(
164            "rk profile --target {} reports what a landing would select",
165            args.target
166        ),
167        format!("rk init --target {} lands the workflow", args.target),
168        format!(
169            "rk adopt --target {} records a landing made before the receipt existed",
170            args.target
171        ),
172        format!(
173            "rk stage --target {} stages this binary's candidate; the rk-setup skill carries the best-effort migration",
174            args.target
175        ),
176    ]);
177    out.emit(&Report {
178        schema: "rk.status/12",
179        landed: false,
180        config: config_state(config, None),
181        profile: None,
182        git: None,
183        capabilities: None,
184        selection: None,
185        rk_version: None,
186        binary_version: None,
187        alignment: None,
188        drift: None,
189        missing: None,
190        stale_pins: None,
191        sentinels: None,
192        record_drift: None,
193        invariant_failures: None,
194        warnings: None,
195        pending: None,
196        violations: args.check.then(|| vec!["no landing".to_owned()]),
197    })?;
198    if args.check {
199        return Err(RkError::check_failed(
200            Diagnostic::new(
201                Reason::StateDrift,
202                format!("no landing at {}, and --check requires one", args.target),
203            )
204            .expected("a target carrying .release-kit/manifest.json")
205            .action("rk init lands the workflow; rk adopt records an existing landing; rk stage and the rk-setup skill carry the migration"),
206        ));
207    }
208    Ok(())
209}
210
211/// What one pass over the record and the disk observed.
212struct Observed {
213    drift_rendered: Vec<String>,
214    drift_seeded: Vec<String>,
215    /// Recorded block destinations whose recorded digest the record's own
216    /// parameters do not reproduce: the record was edited, not the file.
217    parameter_drift: Vec<String>,
218    /// Set differences between what the recorded parameters project —
219    /// the withhold judgment applied — and the destinations the record
220    /// names: a record whose parameters and file list disagree, whichever
221    /// of the two was edited or outgrown.
222    record_drift: Vec<String>,
223    missing: Vec<String>,
224    stale: Vec<StalePin>,
225    sentinels: Vec<(String, usize, String)>,
226    invariants: Vec<InvariantFailure>,
227    /// Reportable conditions that are not violations.
228    warnings: Vec<Warning>,
229    /// Recorded intents this target cannot honour, counted in
230    /// `record_drift` and kept separately because a plain upgrade cannot
231    /// repair them: it re-reads the same recorded answer and refuses. An
232    /// unavailable optional capability is not one of these; it is reported
233    /// in `selection` and omitted, and an upgrade runs over it unharmed.
234    incompatible: Vec<String>,
235    /// The destinations an upgrade would change, or `None` where this
236    /// binary carries no projection for the recorded pair and so cannot
237    /// say.
238    pending: Option<Vec<String>>,
239    /// Every capability the catalog answers for the recorded values.
240    selection: Option<Vec<CapabilityNote>>,
241}
242
243/// Report the target's landing.
244///
245/// # Errors
246///
247/// Returns [`RkError::Missing`] for a target that is not a directory, the
248/// record's own failure taxonomy for an unreadable or unknown record, and
249/// [`RkError::CheckFailed`] under `--check` when the report holds a
250/// violation.
251pub fn run(args: &StatusArgs) -> Result<(), RkError> {
252    let out = Output::new(args.json);
253    if !args.target.is_dir() {
254        return Err(RkError::missing(
255            Diagnostic::new(
256                Reason::TargetNotFound,
257                format!("target {} is not a directory", args.target),
258            )
259            .expected("an existing repository to report on"),
260        ));
261    }
262    let config = crate::config::load(args.target.as_std_path())?;
263    let Some(manifest) = manifest::load(&args.target)? else {
264        return report_absent(out, args, config.as_ref());
265    };
266
267    let config = config_state(config.as_ref(), Some(&manifest));
268    out.result_line(format!("config: {}", config.state));
269    for key in &config.pending {
270        out.result_line(format!(
271            "config pending: {key}; rk upgrade --apply takes it up"
272        ));
273    }
274    let observed = observe(args, &manifest)?;
275    let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
276    render_human(out, args, &manifest, alignment, &observed);
277
278    let violations = violations_of(&observed);
279    out.emit(&Report {
280        schema: "rk.status/12",
281        landed: true,
282        config,
283        profile: Some(manifest.profile.clone()),
284        git: Some(manifest.git.clone()),
285        capabilities: Some(manifest.capabilities.clone()),
286        selection: observed.selection.clone(),
287        rk_version: Some(manifest.rk_version),
288        binary_version: Some(env!("CARGO_PKG_VERSION")),
289        alignment: Some(alignment),
290        drift: Some(Drift {
291            rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
292            seeded: observed.drift_seeded.len(),
293        }),
294        record_drift: Some(observed.record_drift.len()),
295        missing: Some(observed.missing.clone()),
296        stale_pins: Some(observed.stale),
297        sentinels: Some(observed.sentinels.len()),
298        invariant_failures: Some(observed.invariants),
299        warnings: Some(observed.warnings),
300        pending: observed.pending.as_ref().map(Vec::len),
301        violations: args.check.then(|| violations.clone()),
302    })?;
303
304    if args.check && !violations.is_empty() {
305        return Err(RkError::check_failed(
306            Diagnostic::new(
307                Reason::StateDrift,
308                format!(
309                    "the landing is not clean: {} violation{}",
310                    violations.len(),
311                    if violations.len() == 1 { "" } else { "s" }
312                ),
313            )
314            .expected(
315                "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
316            ),
317        ));
318    }
319    Ok(())
320}
321
322/// The check-mode violation lines: rendered drift, missing recorded
323/// files, unresolved sentinels, and invariant failures — the closed set
324/// `landing:status-judges-only-under-check` names.
325fn violations_of(observed: &Observed) -> Vec<String> {
326    observed
327        .drift_rendered
328        .iter()
329        .map(|path| format!("rendered drift: {path}"))
330        .chain(
331            observed
332                .parameter_drift
333                .iter()
334                .map(|path| format!("parameter drift: {path}")),
335        )
336        .chain(
337            observed
338                .record_drift
339                .iter()
340                .map(|reason| format!("record drift: {reason}")),
341        )
342        .chain(
343            observed
344                .missing
345                .iter()
346                .map(|path| format!("missing: {path}")),
347        )
348        .chain(
349            observed
350                .sentinels
351                .iter()
352                .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
353        )
354        .chain(
355            observed
356                .invariants
357                .iter()
358                .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
359        )
360        .collect()
361}
362
363/// One pass over the record and the disk: drift, missing files, stale
364/// pins, and sentinels.
365#[allow(
366    clippy::too_many_lines,
367    reason = "one pass over the record and the disk answers every comparison the report states"
368)]
369fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
370    let mut observed = Observed {
371        drift_rendered: Vec::new(),
372        drift_seeded: Vec::new(),
373        parameter_drift: Vec::new(),
374        record_drift: Vec::new(),
375        missing: Vec::new(),
376        stale: Vec::new(),
377        sentinels: Vec::new(),
378        invariants: Vec::new(),
379        warnings: Vec::new(),
380        incompatible: Vec::new(),
381        pending: None,
382        selection: None,
383    };
384    // One projection serves every reader below, because each asks what
385    // this binary makes of the recorded parameters at this target. A pair
386    // this binary does not carry cannot be projected at all: under a
387    // receipt this binary wrote that is a defect and still fails, and
388    // under a landing from another rk it is a fact to report.
389    let aligned =
390        manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION")) == Alignment::Aligned;
391    let projected = match project(args, manifest) {
392        Ok(projection) => Some(projection),
393        Err(err) if aligned => return Err(err),
394        Err(_) => None,
395    };
396    for file in &manifest.files {
397        let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
398            observed.missing.push(file.destination.clone());
399            continue;
400        };
401        if Digest::of(&bytes) != file.sha256 {
402            match file.kind {
403                Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
404                Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
405                Kind::State => {}
406            }
407        }
408        observed.invariants.extend(invariants::failures(
409            manifest.profile.release.driver.as_deref().unwrap_or(""),
410            manifest.profile.forge.as_deref().unwrap_or(""),
411            &file.destination,
412            &bytes,
413        ));
414        let text = String::from_utf8_lossy(&bytes);
415        for (idx, line) in text.lines().enumerate() {
416            if line.contains(embedded::SENTINEL) {
417                observed.sentinels.push((
418                    file.destination.clone(),
419                    idx + 1,
420                    line.trim().to_owned(),
421                ));
422            }
423        }
424        // A marked document's markers must be well formed even when its
425        // first block matches the receipt: a duplicate hook block still
426        // executes, so an ill-formed document reads as rendered drift,
427        // never as clean.
428        let defective = projected.as_ref().is_some_and(|projection| {
429            projection
430                .collisions
431                .iter()
432                .any(|collision| collision.destination == file.destination)
433        });
434        if defective && !observed.drift_rendered.contains(&file.destination) {
435            observed.drift_rendered.push(file.destination.clone());
436        }
437    }
438    // The cross-file step: a landed file can generate the artifact the
439    // forge actually executes, and the seeds ship no copy of it, so no
440    // recorded digest sees the two disagree. The pair's own rule reads
441    // both off the target's disk.
442    observed.invariants.extend(invariants::target_failures(
443        manifest.profile.release.driver.as_deref().unwrap_or(""),
444        manifest.profile.forge.as_deref().unwrap_or(""),
445        &args.target,
446    ));
447    observed.selection = projected.as_ref().map(|projection| {
448        projection
449            .capabilities
450            .iter()
451            .map(|selection| CapabilityNote::of(selection, projection))
452            .collect()
453    });
454    if let Some(reason) = projected
455        .as_ref()
456        .and_then(|projection| projection.licence_refusal.clone())
457    {
458        observed.warnings.push(Warning {
459            code: CODE_SCANNING_LICENCE,
460            reason,
461        });
462    }
463    // A receipt naming a release intent nothing can honour is judged at
464    // every alignment rather than only where this binary wrote the record.
465    // An unavailable optional capability is not one of these: it reports
466    // through `selection` and lands nothing, and no verb refuses on it.
467    if let Some(projection) = projected.as_ref() {
468        observed.incompatible.clone_from(&projection.record_defects);
469        observed
470            .record_drift
471            .extend(projection.record_defects.iter().cloned());
472    }
473    if aligned && let Some(projection) = projected.as_ref() {
474        observe_parameter_drift(manifest, projection, &mut observed);
475        observe_record_set(manifest, projection, &mut observed.record_drift);
476    }
477    // What an upgrade would change, which is the only honest ground for
478    // telling an operator to run one. The recorded version says who wrote
479    // the receipt, and two releases apart can carry identical bytes for
480    // this pair, so it answers a different question and prompts nothing.
481    observed.pending = projected
482        .as_ref()
483        .map(|projection| pending_of(manifest, &projection.candidates));
484    // Stale means behind, not merely different: a landing from a newer rk
485    // can carry pins ahead of this binary's registry, and that is the
486    // alignment line's story, not a freshness complaint.
487    for (tool, landed) in &manifest.pins {
488        if let Some(available) = registry::version_of(tool)
489            && manifest::version_is_newer(&available, landed)
490        {
491            observed.stale.push(StalePin {
492                tool: tool.clone(),
493                landed: landed.clone(),
494                available,
495            });
496        }
497    }
498    Ok(observed)
499}
500
501/// The receipt-consistency step over every rendered destination.
502///
503/// Recorded digests alone cannot see a receipt edited only at its
504/// parameters, since every file still matches its own record, so every
505/// rendered candidate, whole file or region, as this projection renders
506/// it from the receipt's own parameters, is compared against the digest
507/// the receipt stores for it. Called only where this binary wrote the
508/// receipt: an older landing's files legitimately differ from this
509/// projection, which is the alignment line's story and the upgrade's job,
510/// not parameter drift. A destination already reported as rendered drift
511/// is the file's own story, not the receipt's, and is skipped too.
512fn observe_parameter_drift(manifest: &Manifest, projection: &Projection, observed: &mut Observed) {
513    for candidate in &projection.candidates {
514        if candidate.kind != Kind::Rendered {
515            continue;
516        }
517        let Some(record) = manifest.file(&candidate.destination) else {
518            continue;
519        };
520        if observed.drift_rendered.contains(&candidate.destination)
521            || observed.missing.contains(&candidate.destination)
522        {
523            continue;
524        }
525        if Digest::of(recorded_form(candidate)) != record.sha256 {
526            observed
527                .parameter_drift
528                .push(format!("{} (parameters)", candidate.destination));
529        }
530    }
531}
532
533/// What this binary projects under the receipt's own parameters at this
534/// target, with the same withhold judgment a landing applies, so the
535/// comparison stands against what an upgrade would actually offer.
536fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Projection, RkError> {
537    let evidence = TargetEvidence::gather(&args.target, Some(manifest))?;
538    Projection::compute(&ProjectionInput {
539        params: landing::Params::from_record(manifest),
540        evidence,
541    })
542}
543
544/// The bytes the receipt digests for a candidate: the whole file, or the
545/// marked region alone.
546fn recorded_form(candidate: &Candidate) -> &[u8] {
547    candidate.region.as_deref().unwrap_or(&candidate.bytes)
548}
549
550/// The destinations an upgrade would change, read off the record alone.
551///
552/// A destination the projection adds or drops changes the record either
553/// way, and a `rendered` one whose candidate digest differs from the
554/// recorded digest is rewritten. A `seeded` or `state` destination the
555/// record already names is never rewritten, so only a change of kind
556/// counts for it. Disk drift is a separate story, told by its own lines:
557/// an edited file is the target's doing, not a newer binary's.
558fn pending_of(manifest: &Manifest, projected: &[Candidate]) -> Vec<String> {
559    let mut pending = Vec::new();
560    for entry in projected {
561        let changed = manifest.file(&entry.destination).is_none_or(|record| {
562            record.kind != entry.kind
563                || (entry.kind == Kind::Rendered
564                    && record.sha256 != Digest::of(recorded_form(entry)))
565        });
566        if changed {
567            pending.push(entry.destination.clone());
568        }
569    }
570    for file in &manifest.files {
571        if !projected
572            .iter()
573            .any(|entry| entry.destination == file.destination)
574        {
575            pending.push(file.destination.clone());
576        }
577    }
578    pending.sort();
579    pending.dedup();
580    pending
581}
582
583/// The receipt-set consistency step: the recorded digests judge each
584/// named file, and the region re-render judges the region records, but
585/// neither can see a receipt whose parameters and file list disagree, a
586/// nix flag flipped in the receipt with no file landed, or a
587/// once-withheld capability whose target grew into the supported shape.
588/// So the projection is computed from the receipt's own parameters, the
589/// same withhold judgment applied, and the two destination sets compared
590/// both ways. A destination the projection withholds at this target is
591/// absent because withheld, which is not drift. Called only where this
592/// binary wrote the receipt: an older landing's set legitimately differs,
593/// and that is the alignment line's story.
594fn observe_record_set(
595    manifest: &Manifest,
596    projection: &Projection,
597    record_drift: &mut Vec<String>,
598) {
599    for entry in &projection.candidates {
600        if manifest.file(&entry.destination).is_none() {
601            record_drift.push(format!(
602                "the recorded parameters project {}, which the receipt does not name",
603                entry.destination
604            ));
605        }
606    }
607    for file in &manifest.files {
608        let produced = projection
609            .candidates
610            .iter()
611            .any(|entry| entry.destination == file.destination)
612            || projection
613                .omissions
614                .iter()
615                .any(|omission| omission.destination == file.destination);
616        if !produced {
617            record_drift.push(format!(
618                "the receipt names {}, which the recorded parameters do not project",
619                file.destination
620            ));
621        }
622    }
623}
624
625/// The human lines, identical with and without `--check`.
626#[allow(
627    clippy::too_many_lines,
628    reason = "one pass prints every reportable condition in the order the report states them"
629)]
630fn render_human(
631    out: Output,
632    args: &StatusArgs,
633    manifest: &Manifest,
634    alignment: Alignment,
635    observed: &Observed,
636) {
637    out.result_line(format!(
638        "release-kit {} at {}: {}",
639        manifest.rk_version,
640        args.target,
641        crate::commands::profile::describe(
642            &manifest.profile,
643            &manifest.git,
644            &manifest.capabilities,
645            &manifest.parameters.repo
646        )
647    ));
648    // Every capability the record does not select is information, never
649    // a violation: a product nobody asked for, or one this target's
650    // dimensions cannot take.
651    for note in observed.selection.iter().flatten() {
652        if note.status != "selected" {
653            out.result_line(format!(
654                "capability {}: {}{}",
655                note.id,
656                note.status,
657                note.reason
658                    .as_deref()
659                    .map_or_else(String::new, |reason| format!(" ({reason})"))
660            ));
661        }
662    }
663    if alignment == Alignment::TargetNewer {
664        out.result_line(format!(
665            "binary {} is older than this landing; install the matching rk",
666            env!("CARGO_PKG_VERSION")
667        ));
668    }
669    match observed.pending.as_deref() {
670        None => out.result_line(format!(
671            "this binary carries no projection for the recorded {} release on {}, so what an upgrade would change is unknown",
672            manifest
673                .profile
674                .release
675                .driver
676                .as_deref()
677                .unwrap_or("release-less"),
678            manifest.profile.forge.as_deref().unwrap_or("no forge")
679        )),
680        Some(paths) => {
681            for path in paths {
682                out.result_line(format!("PENDING {path} (this binary would change it)"));
683            }
684        }
685    }
686    for path in &observed.drift_rendered {
687        out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
688    }
689    for path in &observed.parameter_drift {
690        out.result_line(format!(
691            "DRIFT {path}: the recorded parameters do not render the recorded bytes"
692        ));
693    }
694    for reason in &observed.record_drift {
695        out.result_line(format!("DRIFT record: {reason}"));
696    }
697    for path in &observed.drift_seeded {
698        out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
699    }
700    for path in &observed.missing {
701        out.result_line(format!("MISSING {path}"));
702    }
703    for pin in &observed.stale {
704        out.result_line(format!(
705            "STALE {} {} landed, {} in this binary",
706            pin.tool, pin.landed, pin.available
707        ));
708    }
709    for (path, line, text) in &observed.sentinels {
710        out.result_line(format!("SENTINEL {path}:{line}: {text}"));
711    }
712    for failure in &observed.invariants {
713        out.result_line(format!(
714            "INVARIANT {} ({}): {}",
715            failure.destination, failure.code, failure.reason
716        ));
717    }
718    for warning in &observed.warnings {
719        out.result_line(format!("WARNING ({}): {}", warning.code, warning.reason));
720    }
721    let mut next = Vec::new();
722    for failure in &observed.invariants {
723        next.push(format!("{}: {}", failure.destination, failure.remediation));
724    }
725    // The incompatible ones first, and with the override: a plain upgrade
726    // reads the same recorded intent and refuses, so advertising it alone
727    // would send the operator into a loop.
728    if !observed.incompatible.is_empty() {
729        next.push(format!(
730            "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",
731            args.target
732        ));
733    }
734    if observed.record_drift.len() > observed.incompatible.len() {
735        next.push(format!(
736            "rk upgrade --target {} rewrites the receipt from its parameters",
737            args.target
738        ));
739    }
740    if observed
741        .pending
742        .as_deref()
743        .is_none_or(|paths| !paths.is_empty())
744    {
745        next.push(format!(
746            "rk upgrade --target {} takes this landing to {}",
747            args.target,
748            env!("CARGO_PKG_VERSION")
749        ));
750    }
751    next.push(format!(
752        "rk status --check --target {} exits 1 on a violation",
753        args.target
754    ));
755    if manifest.profile.release.mode != ReleaseMode::Automatic {
756        next.retain(|line| !line.contains("rk method operate"));
757    }
758    out.next(&next);
759}
760
761#[cfg(test)]
762mod tests {
763    use super::{Drift, InvariantFailure, Report, StalePin};
764    use crate::landing::CheckoutMode;
765    use crate::profile::{
766        CapabilityRequests, GitWorkflow, ProfileSnapshot, ReleaseIntent, ReleaseMode,
767    };
768
769    /// The complete `rk.status/12` shape, held by snapshot in both the
770    /// landed and absent forms.
771    #[test]
772    fn the_status_report_schema_snapshot_holds() {
773        let landed = Report {
774            schema: "rk.status/12",
775            landed: true,
776            config: super::ConfigState {
777                state: "pending",
778                pending: vec!["profile.release.style".into()],
779            },
780            profile: Some(ProfileSnapshot {
781                technologies: vec!["rust".into()],
782                forge: Some("github".into()),
783                release: ReleaseIntent {
784                    mode: ReleaseMode::Automatic,
785                    driver: Some("rust".into()),
786                    style: Some(crate::landing::Style::Trunk),
787                    line_prefix: Some("release/".into()),
788                },
789            }),
790            git: Some(GitWorkflow {
791                trunk: "master".into(),
792                checkout_mode: CheckoutMode::LinkedWorktree,
793            }),
794            capabilities: Some(CapabilityRequests {
795                nix_packaging: true,
796                reporting_policy: true,
797                scorecard: false,
798                code_scanning: Some(crate::landing::Provider::Semgrep),
799            }),
800            selection: Some(vec![]),
801            rk_version: Some("0.1.0".into()),
802            binary_version: Some("0.2.0"),
803            alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
804            drift: Some(Drift {
805                rendered: 0,
806                seeded: 1,
807            }),
808            missing: Some(vec![]),
809            stale_pins: Some(vec![StalePin {
810                tool: "release-plz".into(),
811                landed: "0.3.160".into(),
812                available: "0.3.170".into(),
813            }]),
814            sentinels: Some(1),
815            record_drift: Some(0),
816            invariant_failures: Some(vec![InvariantFailure {
817                code: "attestations-disabled",
818                destination: "dist-workspace.toml".into(),
819                reason: "github-attestations is not effectively true".into(),
820                remediation: "set github-attestations = true in [dist]",
821            }]),
822            warnings: Some(vec![super::Warning {
823                code: super::CODE_SCANNING_LICENCE,
824                reason: "the target's license, LicenseRef-proprietary, is not one this release recognizes as OSI-approved".into(),
825            }]),
826            pending: Some(2),
827            violations: None,
828        };
829        assert_eq!(
830            serde_json::to_string(&landed).expect("a report serializes"),
831            r#"{"schema":"rk.status/12","landed":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"},"capabilities":{"nix_packaging":true,"reporting_policy":true,"scorecard":false,"code_scanning":"semgrep"},"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,"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, is not one this release recognizes as OSI-approved"}],"pending":2}"#
832        );
833        let absent = Report {
834            landed: false,
835            config: super::ConfigState {
836                state: "absent",
837                pending: vec![],
838            },
839            profile: None,
840            git: None,
841            capabilities: None,
842            selection: None,
843            rk_version: None,
844            binary_version: None,
845            alignment: None,
846            drift: None,
847            missing: None,
848            stale_pins: None,
849            sentinels: None,
850            record_drift: None,
851            invariant_failures: None,
852            warnings: None,
853            pending: None,
854            violations: None,
855            ..landed
856        };
857        assert_eq!(
858            serde_json::to_string(&absent).expect("a report serializes"),
859            r#"{"schema":"rk.status/12","landed":false,"config":{"state":"absent","pending":[]}}"#,
860            "an absent landing reports one field a caller can branch on"
861        );
862    }
863}