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    #[serde(skip_serializing_if = "Option::is_none")]
55    rk_version: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    binary_version: Option<&'static str>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    alignment: Option<Alignment>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    drift: Option<Drift>,
62    /// Recorded destinations absent from the disk.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    missing: Option<Vec<String>>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    stale_pins: Option<Vec<StalePin>>,
67    /// Unresolved judgment sentinels across the landed files.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    sentinels: Option<usize>,
70    /// Invariants a landed file's effective configuration violates —
71    /// judged, never rewritten, because the file stays the target's.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    invariant_failures: Option<Vec<InvariantFailure>>,
74    /// Present only under `--check`: what the judgment failed on.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    violations: Option<Vec<String>>,
77}
78
79/// What one pass over the record and the disk observed.
80struct Observed {
81    drift_rendered: Vec<String>,
82    drift_seeded: Vec<String>,
83    missing: Vec<String>,
84    stale: Vec<StalePin>,
85    sentinels: Vec<(String, usize, String)>,
86    invariants: Vec<InvariantFailure>,
87}
88
89/// Report the target's landing.
90///
91/// # Errors
92///
93/// Returns [`RkError::Missing`] for a target that is not a directory, the
94/// record's own failure taxonomy for an unreadable or unknown record, and
95/// [`RkError::CheckFailed`] under `--check` when the report holds a
96/// violation.
97pub fn run(args: &StatusArgs) -> Result<(), RkError> {
98    let out = Output::new(args.json);
99    if !args.target.is_dir() {
100        return Err(RkError::missing(
101            Diagnostic::new(
102                Reason::TargetNotFound,
103                format!("target {} is not a directory", args.target),
104            )
105            .expected("an existing repository to report on"),
106        ));
107    }
108    let Some(manifest) = manifest::load(&args.target)? else {
109        out.result_line(format!("no landing at {}", args.target));
110        out.next(&[
111            format!(
112                "rk init --tech <tech> --target {} lands the workflow",
113                args.target
114            ),
115            format!(
116                "rk adopt --target {} records a landing made before the record existed",
117                args.target
118            ),
119        ]);
120        out.emit(&Report {
121            schema: "rk.status/2",
122            landed: false,
123            tech: None,
124            forge: None,
125            rk_version: None,
126            binary_version: None,
127            alignment: None,
128            drift: None,
129            missing: None,
130            stale_pins: None,
131            sentinels: None,
132            invariant_failures: None,
133            violations: args.check.then(|| vec!["no landing".to_owned()]),
134        })?;
135        if args.check {
136            return Err(RkError::check_failed(
137                Diagnostic::new(
138                    Reason::StateDrift,
139                    format!("no landing at {}, and --check requires one", args.target),
140                )
141                .expected("a target carrying .release-kit/manifest.json")
142                .action("rk init lands the workflow; rk adopt records an existing landing"),
143            ));
144        }
145        return Ok(());
146    };
147
148    let observed = observe(args, &manifest)?;
149    let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
150    render_human(out, args, &manifest, alignment, &observed);
151
152    let violations = violations_of(&observed);
153    out.emit(&Report {
154        schema: "rk.status/2",
155        landed: true,
156        tech: Some(manifest.tech),
157        forge: Some(manifest.forge),
158        rk_version: Some(manifest.rk_version),
159        binary_version: Some(env!("CARGO_PKG_VERSION")),
160        alignment: Some(alignment),
161        drift: Some(Drift {
162            rendered: observed.drift_rendered.len(),
163            seeded: observed.drift_seeded.len(),
164        }),
165        missing: Some(observed.missing.clone()),
166        stale_pins: Some(observed.stale),
167        sentinels: Some(observed.sentinels.len()),
168        invariant_failures: Some(observed.invariants),
169        violations: args.check.then(|| violations.clone()),
170    })?;
171
172    if args.check && !violations.is_empty() {
173        return Err(RkError::check_failed(
174            Diagnostic::new(
175                Reason::StateDrift,
176                format!(
177                    "the landing is not clean: {} violation{}",
178                    violations.len(),
179                    if violations.len() == 1 { "" } else { "s" }
180                ),
181            )
182            .expected(
183                "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
184            ),
185        ));
186    }
187    Ok(())
188}
189
190/// The check-mode violation lines: rendered drift, missing recorded
191/// files, unresolved sentinels, and invariant failures — the closed set
192/// `landing:status-judges-only-under-check` names.
193fn violations_of(observed: &Observed) -> Vec<String> {
194    observed
195        .drift_rendered
196        .iter()
197        .map(|path| format!("rendered drift: {path}"))
198        .chain(
199            observed
200                .missing
201                .iter()
202                .map(|path| format!("missing: {path}")),
203        )
204        .chain(
205            observed
206                .sentinels
207                .iter()
208                .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
209        )
210        .chain(
211            observed
212                .invariants
213                .iter()
214                .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
215        )
216        .collect()
217}
218
219/// One pass over the record and the disk: drift, missing files, stale
220/// pins, and sentinels.
221fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
222    let mut observed = Observed {
223        drift_rendered: Vec::new(),
224        drift_seeded: Vec::new(),
225        missing: Vec::new(),
226        stale: Vec::new(),
227        sentinels: Vec::new(),
228        invariants: Vec::new(),
229    };
230    for file in &manifest.files {
231        let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
232            observed.missing.push(file.destination.clone());
233            continue;
234        };
235        if Digest::of(&bytes) != file.sha256 {
236            match file.kind {
237                Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
238                Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
239                Kind::State => {}
240            }
241        }
242        observed.invariants.extend(invariants::failures(
243            &manifest.tech,
244            &manifest.forge,
245            &file.destination,
246            &bytes,
247        ));
248        let text = String::from_utf8_lossy(&bytes);
249        for (idx, line) in text.lines().enumerate() {
250            if line.contains(embedded::SENTINEL) {
251                observed.sentinels.push((
252                    file.destination.clone(),
253                    idx + 1,
254                    line.trim().to_owned(),
255                ));
256            }
257        }
258        // The hook file's markers must be well formed even when its first
259        // block matches the record: a duplicate block still executes, so
260        // an ill-formed file reads as rendered drift, never as clean.
261        if file.destination == landing::HOOKS_DESTINATION
262            && !observed.drift_rendered.contains(&file.destination)
263            && landing::hooks_file_defect(&args.target)?.is_some()
264        {
265            observed.drift_rendered.push(file.destination.clone());
266        }
267    }
268    // Stale means behind, not merely different: a landing from a newer rk
269    // can carry pins ahead of this binary's registry, and that is the
270    // alignment line's story, not a freshness complaint.
271    for (tool, landed) in &manifest.pins {
272        if let Some(available) = registry::version_of(tool) {
273            if manifest::version_is_newer(&available, landed) {
274                observed.stale.push(StalePin {
275                    tool: tool.clone(),
276                    landed: landed.clone(),
277                    available,
278                });
279            }
280        }
281    }
282    Ok(observed)
283}
284
285/// The human lines, identical with and without `--check`.
286fn render_human(
287    out: Output,
288    args: &StatusArgs,
289    manifest: &Manifest,
290    alignment: Alignment,
291    observed: &Observed,
292) {
293    out.result_line(format!(
294        "release-kit {} ({}, {}) at {}",
295        manifest.rk_version, manifest.tech, manifest.forge, args.target
296    ));
297    match alignment {
298        Alignment::BinaryNewer => out.result_line(format!(
299            "binary {} is newer; run 'rk upgrade'",
300            env!("CARGO_PKG_VERSION")
301        )),
302        Alignment::TargetNewer => out.result_line(format!(
303            "binary {} is older than this landing; install the matching rk",
304            env!("CARGO_PKG_VERSION")
305        )),
306        Alignment::Aligned => {}
307    }
308    for path in &observed.drift_rendered {
309        out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
310    }
311    for path in &observed.drift_seeded {
312        out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
313    }
314    for path in &observed.missing {
315        out.result_line(format!("MISSING {path}"));
316    }
317    for pin in &observed.stale {
318        out.result_line(format!(
319            "STALE {} {} landed, {} in this binary",
320            pin.tool, pin.landed, pin.available
321        ));
322    }
323    for (path, line, text) in &observed.sentinels {
324        out.result_line(format!("SENTINEL {path}:{line}: {text}"));
325    }
326    for failure in &observed.invariants {
327        out.result_line(format!(
328            "INVARIANT {} ({}): {}",
329            failure.destination, failure.code, failure.reason
330        ));
331    }
332    let mut next = Vec::new();
333    for failure in &observed.invariants {
334        next.push(format!("{}: {}", failure.destination, failure.remediation));
335    }
336    if alignment == Alignment::BinaryNewer {
337        next.push(format!(
338            "rk upgrade --target {} takes this landing to {}",
339            args.target,
340            env!("CARGO_PKG_VERSION")
341        ));
342    }
343    next.push(format!(
344        "rk status --check --target {} exits 1 on a violation",
345        args.target
346    ));
347    out.next(&next);
348}
349
350#[cfg(test)]
351mod tests {
352    #![allow(clippy::expect_used)]
353
354    use super::{Drift, InvariantFailure, Report, StalePin};
355
356    /// The complete `rk.status/1` shape, held by snapshot in both the
357    /// landed and absent forms.
358    #[test]
359    fn the_status_report_schema_snapshot_holds() {
360        let landed = Report {
361            schema: "rk.status/2",
362            landed: true,
363            tech: Some("rust".into()),
364            forge: Some("github".into()),
365            rk_version: Some("0.1.0".into()),
366            binary_version: Some("0.2.0"),
367            alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
368            drift: Some(Drift {
369                rendered: 0,
370                seeded: 1,
371            }),
372            missing: Some(vec![]),
373            stale_pins: Some(vec![StalePin {
374                tool: "release-plz".into(),
375                landed: "0.3.160".into(),
376                available: "0.3.170".into(),
377            }]),
378            sentinels: Some(1),
379            invariant_failures: Some(vec![InvariantFailure {
380                code: "attestations-disabled",
381                destination: "dist-workspace.toml".into(),
382                reason: "github-attestations is not effectively true".into(),
383                remediation: "set github-attestations = true in [dist]",
384            }]),
385            violations: None,
386        };
387        assert_eq!(
388            serde_json::to_string(&landed).expect("a report serializes"),
389            r#"{"schema":"rk.status/2","landed":true,"tech":"rust","forge":"github","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]"}]}"#
390        );
391        let absent = Report {
392            landed: false,
393            tech: None,
394            forge: None,
395            rk_version: None,
396            binary_version: None,
397            alignment: None,
398            drift: None,
399            missing: None,
400            stale_pins: None,
401            sentinels: None,
402            invariant_failures: None,
403            violations: None,
404            ..landed
405        };
406        assert_eq!(
407            serde_json::to_string(&absent).expect("a report serializes"),
408            r#"{"schema":"rk.status/2","landed":false}"#,
409            "an absent landing reports one field a caller can branch on"
410        );
411    }
412}