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::manifest::{self, Alignment, Manifest};
18use crate::landing::{self, Kind};
19use crate::output::Output;
20use crate::{embedded, registry};
21
22/// Drift counts by owned kind; `state` files are never compared.
23#[derive(Debug, Serialize)]
24struct Drift {
25    /// Edits to files release-kit owns — the violation class.
26    rendered: usize,
27    /// Edits to files the target owns — expected and informational.
28    seeded: usize,
29}
30
31/// One recorded pin that is behind this binary's registry.
32#[derive(Debug, Serialize)]
33struct StalePin {
34    /// The tool's registry name.
35    tool: String,
36    /// The version the landing recorded.
37    landed: String,
38    /// The version this binary's registry pins.
39    available: String,
40}
41
42/// The machine form of a status report.
43#[derive(Debug, Serialize)]
44struct Report {
45    /// The shape version of this document.
46    schema: &'static str,
47    /// Whether a landing record exists; every other field needs one.
48    landed: bool,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    tech: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    forge: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    rk_version: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    binary_version: Option<&'static str>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    alignment: Option<Alignment>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    drift: Option<Drift>,
61    /// Recorded destinations absent from the disk.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    missing: Option<Vec<String>>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    stale_pins: Option<Vec<StalePin>>,
66    /// Unresolved judgment sentinels across the landed files.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    sentinels: Option<usize>,
69    /// Present only under `--check`: what the judgment failed on.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    violations: Option<Vec<String>>,
72}
73
74/// What one pass over the record and the disk observed.
75struct Observed {
76    drift_rendered: Vec<String>,
77    drift_seeded: Vec<String>,
78    missing: Vec<String>,
79    stale: Vec<StalePin>,
80    sentinels: Vec<(String, usize, String)>,
81}
82
83/// Report the target's landing.
84///
85/// # Errors
86///
87/// Returns [`RkError::Missing`] for a target that is not a directory, the
88/// record's own failure taxonomy for an unreadable or unknown record, and
89/// [`RkError::CheckFailed`] under `--check` when the report holds a
90/// violation.
91pub fn run(args: &StatusArgs) -> Result<(), RkError> {
92    let out = Output::new(args.json);
93    if !args.target.is_dir() {
94        return Err(RkError::missing(
95            Diagnostic::new(
96                Reason::TargetNotFound,
97                format!("target {} is not a directory", args.target),
98            )
99            .expected("an existing repository to report on"),
100        ));
101    }
102    let Some(manifest) = manifest::load(&args.target)? else {
103        out.result_line(format!("no landing at {}", args.target));
104        out.next(&[
105            format!(
106                "rk init --tech <tech> --target {} lands the workflow",
107                args.target
108            ),
109            format!(
110                "rk adopt --target {} records a landing made before the record existed",
111                args.target
112            ),
113        ]);
114        out.emit(&Report {
115            schema: "rk.status/1",
116            landed: false,
117            tech: None,
118            forge: None,
119            rk_version: None,
120            binary_version: None,
121            alignment: None,
122            drift: None,
123            missing: None,
124            stale_pins: None,
125            sentinels: None,
126            violations: args.check.then(|| vec!["no landing".to_owned()]),
127        })?;
128        if args.check {
129            return Err(RkError::check_failed(
130                Diagnostic::new(
131                    Reason::StateDrift,
132                    format!("no landing at {}, and --check requires one", args.target),
133                )
134                .expected("a target carrying .release-kit/manifest.json")
135                .action("rk init lands the workflow; rk adopt records an existing landing"),
136            ));
137        }
138        return Ok(());
139    };
140
141    let observed = observe(args, &manifest)?;
142    let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
143    render_human(out, args, &manifest, alignment, &observed);
144
145    let violations: Vec<String> = observed
146        .drift_rendered
147        .iter()
148        .map(|path| format!("rendered drift: {path}"))
149        .chain(
150            observed
151                .missing
152                .iter()
153                .map(|path| format!("missing: {path}")),
154        )
155        .chain(
156            observed
157                .sentinels
158                .iter()
159                .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
160        )
161        .collect();
162    out.emit(&Report {
163        schema: "rk.status/1",
164        landed: true,
165        tech: Some(manifest.tech),
166        forge: Some(manifest.forge),
167        rk_version: Some(manifest.rk_version),
168        binary_version: Some(env!("CARGO_PKG_VERSION")),
169        alignment: Some(alignment),
170        drift: Some(Drift {
171            rendered: observed.drift_rendered.len(),
172            seeded: observed.drift_seeded.len(),
173        }),
174        missing: Some(observed.missing.clone()),
175        stale_pins: Some(observed.stale),
176        sentinels: Some(observed.sentinels.len()),
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("no rendered drift, no missing recorded file, no unresolved sentinel"),
191        ));
192    }
193    Ok(())
194}
195
196/// One pass over the record and the disk: drift, missing files, stale
197/// pins, and sentinels.
198fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
199    let mut observed = Observed {
200        drift_rendered: Vec::new(),
201        drift_seeded: Vec::new(),
202        missing: Vec::new(),
203        stale: Vec::new(),
204        sentinels: Vec::new(),
205    };
206    for file in &manifest.files {
207        let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
208            observed.missing.push(file.destination.clone());
209            continue;
210        };
211        if Digest::of(&bytes) != file.sha256 {
212            match file.kind {
213                Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
214                Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
215                Kind::State => {}
216            }
217        }
218        let text = String::from_utf8_lossy(&bytes);
219        for (idx, line) in text.lines().enumerate() {
220            if line.contains(embedded::SENTINEL) {
221                observed.sentinels.push((
222                    file.destination.clone(),
223                    idx + 1,
224                    line.trim().to_owned(),
225                ));
226            }
227        }
228        // The hook file's markers must be well formed even when its first
229        // block matches the record: a duplicate block still executes, so
230        // an ill-formed file reads as rendered drift, never as clean.
231        if file.destination == landing::HOOKS_DESTINATION
232            && !observed.drift_rendered.contains(&file.destination)
233            && landing::hooks_file_defect(&args.target)?.is_some()
234        {
235            observed.drift_rendered.push(file.destination.clone());
236        }
237    }
238    // Stale means behind, not merely different: a landing from a newer rk
239    // can carry pins ahead of this binary's registry, and that is the
240    // alignment line's story, not a freshness complaint.
241    for (tool, landed) in &manifest.pins {
242        if let Some(available) = registry::version_of(tool) {
243            if manifest::version_is_newer(&available, landed) {
244                observed.stale.push(StalePin {
245                    tool: tool.clone(),
246                    landed: landed.clone(),
247                    available,
248                });
249            }
250        }
251    }
252    Ok(observed)
253}
254
255/// The human lines, identical with and without `--check`.
256fn render_human(
257    out: Output,
258    args: &StatusArgs,
259    manifest: &Manifest,
260    alignment: Alignment,
261    observed: &Observed,
262) {
263    out.result_line(format!(
264        "release-kit {} ({}, {}) at {}",
265        manifest.rk_version, manifest.tech, manifest.forge, args.target
266    ));
267    match alignment {
268        Alignment::BinaryNewer => out.result_line(format!(
269            "binary {} is newer; run 'rk upgrade'",
270            env!("CARGO_PKG_VERSION")
271        )),
272        Alignment::TargetNewer => out.result_line(format!(
273            "binary {} is older than this landing; install the matching rk",
274            env!("CARGO_PKG_VERSION")
275        )),
276        Alignment::Aligned => {}
277    }
278    for path in &observed.drift_rendered {
279        out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
280    }
281    for path in &observed.drift_seeded {
282        out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
283    }
284    for path in &observed.missing {
285        out.result_line(format!("MISSING {path}"));
286    }
287    for pin in &observed.stale {
288        out.result_line(format!(
289            "STALE {} {} landed, {} in this binary",
290            pin.tool, pin.landed, pin.available
291        ));
292    }
293    for (path, line, text) in &observed.sentinels {
294        out.result_line(format!("SENTINEL {path}:{line}: {text}"));
295    }
296    let mut next = Vec::new();
297    if alignment == Alignment::BinaryNewer {
298        next.push(format!(
299            "rk upgrade --target {} takes this landing to {}",
300            args.target,
301            env!("CARGO_PKG_VERSION")
302        ));
303    }
304    next.push(format!(
305        "rk status --check --target {} exits 1 on a violation",
306        args.target
307    ));
308    out.next(&next);
309}
310
311#[cfg(test)]
312mod tests {
313    #![allow(clippy::expect_used)]
314
315    use super::{Drift, Report, StalePin};
316
317    /// The complete `rk.status/1` shape, held by snapshot in both the
318    /// landed and absent forms.
319    #[test]
320    fn the_status_report_schema_snapshot_holds() {
321        let landed = Report {
322            schema: "rk.status/1",
323            landed: true,
324            tech: Some("rust".into()),
325            forge: Some("github".into()),
326            rk_version: Some("0.1.0".into()),
327            binary_version: Some("0.2.0"),
328            alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
329            drift: Some(Drift {
330                rendered: 0,
331                seeded: 1,
332            }),
333            missing: Some(vec![]),
334            stale_pins: Some(vec![StalePin {
335                tool: "release-plz".into(),
336                landed: "0.3.160".into(),
337                available: "0.3.170".into(),
338            }]),
339            sentinels: Some(1),
340            violations: None,
341        };
342        assert_eq!(
343            serde_json::to_string(&landed).expect("a report serializes"),
344            r#"{"schema":"rk.status/1","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}"#
345        );
346        let absent = Report {
347            landed: false,
348            tech: None,
349            forge: None,
350            rk_version: None,
351            binary_version: None,
352            alignment: None,
353            drift: None,
354            missing: None,
355            stale_pins: None,
356            sentinels: None,
357            violations: None,
358            ..landed
359        };
360        assert_eq!(
361            serde_json::to_string(&absent).expect("a report serializes"),
362            r#"{"schema":"rk.status/1","landed":false}"#,
363            "an absent landing reports one field a caller can branch on"
364        );
365    }
366}