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, 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    /// Invariants a landed file's effective configuration violates —
81    /// judged, never rewritten, because the file stays the target's.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    invariant_failures: Option<Vec<InvariantFailure>>,
84    /// Present only under `--check`: what the judgment failed on.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    violations: Option<Vec<String>>,
87}
88
89/// What one pass over the record and the disk observed.
90struct Observed {
91    drift_rendered: Vec<String>,
92    drift_seeded: Vec<String>,
93    /// Recorded block destinations whose recorded digest the record's own
94    /// parameters do not reproduce: the record was edited, not the file.
95    parameter_drift: Vec<String>,
96    missing: Vec<String>,
97    stale: Vec<StalePin>,
98    sentinels: Vec<(String, usize, String)>,
99    invariants: Vec<InvariantFailure>,
100}
101
102/// Report the target's landing.
103///
104/// # Errors
105///
106/// Returns [`RkError::Missing`] for a target that is not a directory, the
107/// record's own failure taxonomy for an unreadable or unknown record, and
108/// [`RkError::CheckFailed`] under `--check` when the report holds a
109/// violation.
110pub fn run(args: &StatusArgs) -> Result<(), RkError> {
111    let out = Output::new(args.json);
112    if !args.target.is_dir() {
113        return Err(RkError::missing(
114            Diagnostic::new(
115                Reason::TargetNotFound,
116                format!("target {} is not a directory", args.target),
117            )
118            .expected("an existing repository to report on"),
119        ));
120    }
121    let Some(manifest) = manifest::load(&args.target)? else {
122        out.result_line(format!("no landing at {}", args.target));
123        out.next(&[
124            format!(
125                "rk init --tech <tech> --target {} lands the workflow",
126                args.target
127            ),
128            format!(
129                "rk adopt --target {} records a landing made before the record existed",
130                args.target
131            ),
132        ]);
133        out.emit(&Report {
134            schema: "rk.status/5",
135            landed: false,
136            tech: None,
137            forge: None,
138            workflow: None,
139            style: None,
140            nix: None,
141            rk_version: None,
142            binary_version: None,
143            alignment: None,
144            drift: None,
145            missing: None,
146            stale_pins: None,
147            sentinels: None,
148            invariant_failures: None,
149            violations: args.check.then(|| vec!["no landing".to_owned()]),
150        })?;
151        if args.check {
152            return Err(RkError::check_failed(
153                Diagnostic::new(
154                    Reason::StateDrift,
155                    format!("no landing at {}, and --check requires one", args.target),
156                )
157                .expected("a target carrying .release-kit/manifest.json")
158                .action("rk init lands the workflow; rk adopt records an existing landing"),
159            ));
160        }
161        return Ok(());
162    };
163
164    let observed = observe(args, &manifest)?;
165    let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
166    render_human(out, args, &manifest, alignment, &observed);
167
168    let violations = violations_of(&observed);
169    out.emit(&Report {
170        schema: "rk.status/5",
171        landed: true,
172        tech: Some(manifest.tech),
173        forge: Some(manifest.forge),
174        workflow: Some(manifest.parameters.workflow.as_str()),
175        style: manifest.parameters.style.map(manifest::Style::as_str),
176        nix: Some(manifest.parameters.nix),
177        rk_version: Some(manifest.rk_version),
178        binary_version: Some(env!("CARGO_PKG_VERSION")),
179        alignment: Some(alignment),
180        drift: Some(Drift {
181            rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
182            seeded: observed.drift_seeded.len(),
183        }),
184        missing: Some(observed.missing.clone()),
185        stale_pins: Some(observed.stale),
186        sentinels: Some(observed.sentinels.len()),
187        invariant_failures: Some(observed.invariants),
188        violations: args.check.then(|| violations.clone()),
189    })?;
190
191    if args.check && !violations.is_empty() {
192        return Err(RkError::check_failed(
193            Diagnostic::new(
194                Reason::StateDrift,
195                format!(
196                    "the landing is not clean: {} violation{}",
197                    violations.len(),
198                    if violations.len() == 1 { "" } else { "s" }
199                ),
200            )
201            .expected(
202                "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
203            ),
204        ));
205    }
206    Ok(())
207}
208
209/// The check-mode violation lines: rendered drift, missing recorded
210/// files, unresolved sentinels, and invariant failures — the closed set
211/// `landing:status-judges-only-under-check` names.
212fn violations_of(observed: &Observed) -> Vec<String> {
213    observed
214        .drift_rendered
215        .iter()
216        .map(|path| format!("rendered drift: {path}"))
217        .chain(
218            observed
219                .parameter_drift
220                .iter()
221                .map(|path| format!("parameter drift: {path}")),
222        )
223        .chain(
224            observed
225                .missing
226                .iter()
227                .map(|path| format!("missing: {path}")),
228        )
229        .chain(
230            observed
231                .sentinels
232                .iter()
233                .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
234        )
235        .chain(
236            observed
237                .invariants
238                .iter()
239                .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
240        )
241        .collect()
242}
243
244/// One pass over the record and the disk: drift, missing files, stale
245/// pins, and sentinels.
246fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
247    let mut observed = Observed {
248        drift_rendered: Vec::new(),
249        drift_seeded: Vec::new(),
250        parameter_drift: Vec::new(),
251        missing: Vec::new(),
252        stale: Vec::new(),
253        sentinels: Vec::new(),
254        invariants: Vec::new(),
255    };
256    for file in &manifest.files {
257        let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
258            observed.missing.push(file.destination.clone());
259            continue;
260        };
261        if Digest::of(&bytes) != file.sha256 {
262            match file.kind {
263                Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
264                Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
265                Kind::State => {}
266            }
267        }
268        observed.invariants.extend(invariants::failures(
269            &manifest.tech,
270            &manifest.forge,
271            &file.destination,
272            &bytes,
273        ));
274        let text = String::from_utf8_lossy(&bytes);
275        for (idx, line) in text.lines().enumerate() {
276            if line.contains(embedded::SENTINEL) {
277                observed.sentinels.push((
278                    file.destination.clone(),
279                    idx + 1,
280                    line.trim().to_owned(),
281                ));
282            }
283        }
284        // The hook file's markers must be well formed even when its first
285        // block matches the record: a duplicate block still executes, so
286        // an ill-formed file reads as rendered drift, never as clean.
287        if file.destination == landing::HOOKS_DESTINATION
288            && !observed.drift_rendered.contains(&file.destination)
289            && landing::hooks_file_defect(&args.target)?.is_some()
290        {
291            observed.drift_rendered.push(file.destination.clone());
292        }
293    }
294    // The record-consistency step: recorded digests alone cannot see a
295    // manifest edited only at its parameters — every file still matches
296    // its own record — so the two mode-bearing block destinations are
297    // re-rendered from the record's own parameters and compared against
298    // the digest the record stores for each. Only where the recorded
299    // payload is this binary's: an older landing's blocks legitimately
300    // differ from this payload's candidate — that is the alignment line's
301    // story and the upgrade's job, not parameter drift. A destination
302    // already reported as rendered drift is the file's own story, not the
303    // record's, and is skipped too.
304    let same_payload = manifest.payload_sha256 == crate::commands::payload::report().payload_sha256;
305    for (destination, template) in [
306        (
307            landing::AGENTS_DESTINATION,
308            landing::routing_block(manifest.parameters.workflow),
309        ),
310        (
311            landing::HOOKS_DESTINATION,
312            landing::hooks_block(manifest.parameters.workflow),
313        ),
314    ] {
315        if !same_payload {
316            break;
317        }
318        let Some(record) = manifest.file(destination) else {
319            continue;
320        };
321        if observed
322            .drift_rendered
323            .iter()
324            .any(|path| path == destination)
325            || observed.missing.iter().any(|path| path == destination)
326        {
327            continue;
328        }
329        let candidate = landing::render(
330            template.as_bytes(),
331            &manifest.parameters.repo,
332            &manifest.parameters.scopes,
333            manifest.parameters.style,
334        );
335        if Digest::of(&candidate) != record.sha256 {
336            observed
337                .parameter_drift
338                .push(format!("{destination} (parameters.workflow)"));
339        }
340    }
341    // Stale means behind, not merely different: a landing from a newer rk
342    // can carry pins ahead of this binary's registry, and that is the
343    // alignment line's story, not a freshness complaint.
344    for (tool, landed) in &manifest.pins {
345        if let Some(available) = registry::version_of(tool) {
346            if manifest::version_is_newer(&available, landed) {
347                observed.stale.push(StalePin {
348                    tool: tool.clone(),
349                    landed: landed.clone(),
350                    available,
351                });
352            }
353        }
354    }
355    Ok(observed)
356}
357
358/// The human lines, identical with and without `--check`.
359fn render_human(
360    out: Output,
361    args: &StatusArgs,
362    manifest: &Manifest,
363    alignment: Alignment,
364    observed: &Observed,
365) {
366    out.result_line(format!(
367        "release-kit {} ({}, {}, {} workflow, {} style{}) at {}",
368        manifest.rk_version,
369        manifest.tech,
370        manifest.forge,
371        manifest.parameters.workflow.as_str(),
372        manifest
373            .parameters
374            .style
375            .map_or("unrecorded", manifest::Style::as_str),
376        if manifest.parameters.nix { ", nix" } else { "" },
377        args.target
378    ));
379    match alignment {
380        Alignment::BinaryNewer => out.result_line(format!(
381            "binary {} is newer; run 'rk upgrade'",
382            env!("CARGO_PKG_VERSION")
383        )),
384        Alignment::TargetNewer => out.result_line(format!(
385            "binary {} is older than this landing; install the matching rk",
386            env!("CARGO_PKG_VERSION")
387        )),
388        Alignment::Aligned => {}
389    }
390    for path in &observed.drift_rendered {
391        out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
392    }
393    for path in &observed.parameter_drift {
394        out.result_line(format!(
395            "DRIFT {path}: the recorded parameters do not render the recorded bytes"
396        ));
397    }
398    for path in &observed.drift_seeded {
399        out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
400    }
401    for path in &observed.missing {
402        out.result_line(format!("MISSING {path}"));
403    }
404    for pin in &observed.stale {
405        out.result_line(format!(
406            "STALE {} {} landed, {} in this binary",
407            pin.tool, pin.landed, pin.available
408        ));
409    }
410    for (path, line, text) in &observed.sentinels {
411        out.result_line(format!("SENTINEL {path}:{line}: {text}"));
412    }
413    for failure in &observed.invariants {
414        out.result_line(format!(
415            "INVARIANT {} ({}): {}",
416            failure.destination, failure.code, failure.reason
417        ));
418    }
419    let mut next = Vec::new();
420    for failure in &observed.invariants {
421        next.push(format!("{}: {}", failure.destination, failure.remediation));
422    }
423    if alignment == Alignment::BinaryNewer {
424        next.push(format!(
425            "rk upgrade --target {} takes this landing to {}",
426            args.target,
427            env!("CARGO_PKG_VERSION")
428        ));
429    }
430    next.push(format!(
431        "rk status --check --target {} exits 1 on a violation",
432        args.target
433    ));
434    out.next(&next);
435}
436
437#[cfg(test)]
438mod tests {
439    #![allow(clippy::expect_used)]
440
441    use super::{Drift, InvariantFailure, Report, StalePin};
442
443    /// The complete `rk.status/5` shape, held by snapshot in both the
444    /// landed and absent forms.
445    #[test]
446    fn the_status_report_schema_snapshot_holds() {
447        let landed = Report {
448            schema: "rk.status/5",
449            landed: true,
450            tech: Some("rust".into()),
451            forge: Some("github".into()),
452            workflow: Some("worktree"),
453            style: Some("trunk"),
454            nix: Some(true),
455            rk_version: Some("0.1.0".into()),
456            binary_version: Some("0.2.0"),
457            alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
458            drift: Some(Drift {
459                rendered: 0,
460                seeded: 1,
461            }),
462            missing: Some(vec![]),
463            stale_pins: Some(vec![StalePin {
464                tool: "release-plz".into(),
465                landed: "0.3.160".into(),
466                available: "0.3.170".into(),
467            }]),
468            sentinels: Some(1),
469            invariant_failures: Some(vec![InvariantFailure {
470                code: "attestations-disabled",
471                destination: "dist-workspace.toml".into(),
472                reason: "github-attestations is not effectively true".into(),
473                remediation: "set github-attestations = true in [dist]",
474            }]),
475            violations: None,
476        };
477        assert_eq!(
478            serde_json::to_string(&landed).expect("a report serializes"),
479            r#"{"schema":"rk.status/5","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,"invariant_failures":[{"code":"attestations-disabled","destination":"dist-workspace.toml","reason":"github-attestations is not effectively true","remediation":"set github-attestations = true in [dist]"}]}"#
480        );
481        let absent = Report {
482            landed: false,
483            tech: None,
484            forge: None,
485            workflow: None,
486            style: None,
487            nix: None,
488            rk_version: None,
489            binary_version: None,
490            alignment: None,
491            drift: None,
492            missing: None,
493            stale_pins: None,
494            sentinels: None,
495            invariant_failures: None,
496            violations: None,
497            ..landed
498        };
499        assert_eq!(
500            serde_json::to_string(&absent).expect("a report serializes"),
501            r#"{"schema":"rk.status/5","landed":false}"#,
502            "an absent landing reports one field a caller can branch on"
503        );
504    }
505}