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