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