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