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