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