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