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