1use 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#[derive(Debug, Serialize)]
25struct Drift {
26 rendered: usize,
28 seeded: usize,
30}
31
32#[derive(Debug, Serialize)]
34struct StalePin {
35 tool: String,
37 landed: String,
39 available: String,
41}
42
43#[derive(Debug, Serialize)]
45struct Report {
46 schema: &'static str,
48 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")]
56 workflow: Option<&'static str>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 style: Option<&'static str>,
60 #[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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
79 sentinels: Option<usize>,
80 #[serde(skip_serializing_if = "Option::is_none")]
83 invariant_failures: Option<Vec<InvariantFailure>>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 violations: Option<Vec<String>>,
87}
88
89struct Observed {
91 drift_rendered: Vec<String>,
92 drift_seeded: Vec<String>,
93 parameter_drift: Vec<String>,
96 missing: Vec<String>,
97 stale: Vec<StalePin>,
98 sentinels: Vec<(String, usize, String)>,
99 invariants: Vec<InvariantFailure>,
100}
101
102pub fn run(args: &StatusArgs) -> Result<(), RkError> {
111 let out = Output::new(args.json);
112 if !args.target.is_dir() {
113 return Err(RkError::missing(
114 Diagnostic::new(
115 Reason::TargetNotFound,
116 format!("target {} is not a directory", args.target),
117 )
118 .expected("an existing repository to report on"),
119 ));
120 }
121 let Some(manifest) = manifest::load(&args.target)? else {
122 out.result_line(format!("no landing at {}", args.target));
123 out.next(&[
124 format!(
125 "rk init --tech <tech> --target {} lands the workflow",
126 args.target
127 ),
128 format!(
129 "rk adopt --target {} records a landing made before the record existed",
130 args.target
131 ),
132 ]);
133 out.emit(&Report {
134 schema: "rk.status/5",
135 landed: false,
136 tech: None,
137 forge: None,
138 workflow: None,
139 style: None,
140 nix: None,
141 rk_version: None,
142 binary_version: None,
143 alignment: None,
144 drift: None,
145 missing: None,
146 stale_pins: None,
147 sentinels: None,
148 invariant_failures: None,
149 violations: args.check.then(|| vec!["no landing".to_owned()]),
150 })?;
151 if args.check {
152 return Err(RkError::check_failed(
153 Diagnostic::new(
154 Reason::StateDrift,
155 format!("no landing at {}, and --check requires one", args.target),
156 )
157 .expected("a target carrying .release-kit/manifest.json")
158 .action("rk init lands the workflow; rk adopt records an existing landing"),
159 ));
160 }
161 return Ok(());
162 };
163
164 let observed = observe(args, &manifest)?;
165 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
166 render_human(out, args, &manifest, alignment, &observed);
167
168 let violations = violations_of(&observed);
169 out.emit(&Report {
170 schema: "rk.status/5",
171 landed: true,
172 tech: Some(manifest.tech),
173 forge: Some(manifest.forge),
174 workflow: Some(manifest.parameters.workflow.as_str()),
175 style: manifest.parameters.style.map(manifest::Style::as_str),
176 nix: Some(manifest.parameters.nix),
177 rk_version: Some(manifest.rk_version),
178 binary_version: Some(env!("CARGO_PKG_VERSION")),
179 alignment: Some(alignment),
180 drift: Some(Drift {
181 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
182 seeded: observed.drift_seeded.len(),
183 }),
184 missing: Some(observed.missing.clone()),
185 stale_pins: Some(observed.stale),
186 sentinels: Some(observed.sentinels.len()),
187 invariant_failures: Some(observed.invariants),
188 violations: args.check.then(|| violations.clone()),
189 })?;
190
191 if args.check && !violations.is_empty() {
192 return Err(RkError::check_failed(
193 Diagnostic::new(
194 Reason::StateDrift,
195 format!(
196 "the landing is not clean: {} violation{}",
197 violations.len(),
198 if violations.len() == 1 { "" } else { "s" }
199 ),
200 )
201 .expected(
202 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
203 ),
204 ));
205 }
206 Ok(())
207}
208
209fn violations_of(observed: &Observed) -> Vec<String> {
213 observed
214 .drift_rendered
215 .iter()
216 .map(|path| format!("rendered drift: {path}"))
217 .chain(
218 observed
219 .parameter_drift
220 .iter()
221 .map(|path| format!("parameter drift: {path}")),
222 )
223 .chain(
224 observed
225 .missing
226 .iter()
227 .map(|path| format!("missing: {path}")),
228 )
229 .chain(
230 observed
231 .sentinels
232 .iter()
233 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
234 )
235 .chain(
236 observed
237 .invariants
238 .iter()
239 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
240 )
241 .collect()
242}
243
244fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
247 let mut observed = Observed {
248 drift_rendered: Vec::new(),
249 drift_seeded: Vec::new(),
250 parameter_drift: Vec::new(),
251 missing: Vec::new(),
252 stale: Vec::new(),
253 sentinels: Vec::new(),
254 invariants: Vec::new(),
255 };
256 for file in &manifest.files {
257 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
258 observed.missing.push(file.destination.clone());
259 continue;
260 };
261 if Digest::of(&bytes) != file.sha256 {
262 match file.kind {
263 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
264 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
265 Kind::State => {}
266 }
267 }
268 observed.invariants.extend(invariants::failures(
269 &manifest.tech,
270 &manifest.forge,
271 &file.destination,
272 &bytes,
273 ));
274 let text = String::from_utf8_lossy(&bytes);
275 for (idx, line) in text.lines().enumerate() {
276 if line.contains(embedded::SENTINEL) {
277 observed.sentinels.push((
278 file.destination.clone(),
279 idx + 1,
280 line.trim().to_owned(),
281 ));
282 }
283 }
284 if file.destination == landing::HOOKS_DESTINATION
288 && !observed.drift_rendered.contains(&file.destination)
289 && landing::hooks_file_defect(&args.target)?.is_some()
290 {
291 observed.drift_rendered.push(file.destination.clone());
292 }
293 }
294 let same_payload = manifest.payload_sha256 == crate::commands::payload::report().payload_sha256;
305 for (destination, template) in [
306 (
307 landing::AGENTS_DESTINATION,
308 landing::routing_block(manifest.parameters.workflow),
309 ),
310 (
311 landing::HOOKS_DESTINATION,
312 landing::hooks_block(manifest.parameters.workflow),
313 ),
314 ] {
315 if !same_payload {
316 break;
317 }
318 let Some(record) = manifest.file(destination) else {
319 continue;
320 };
321 if observed
322 .drift_rendered
323 .iter()
324 .any(|path| path == destination)
325 || observed.missing.iter().any(|path| path == destination)
326 {
327 continue;
328 }
329 let candidate = landing::render(
330 template.as_bytes(),
331 &manifest.parameters.repo,
332 &manifest.parameters.scopes,
333 manifest.parameters.style,
334 );
335 if Digest::of(&candidate) != record.sha256 {
336 observed
337 .parameter_drift
338 .push(format!("{destination} (parameters.workflow)"));
339 }
340 }
341 for (tool, landed) in &manifest.pins {
345 if let Some(available) = registry::version_of(tool) {
346 if manifest::version_is_newer(&available, landed) {
347 observed.stale.push(StalePin {
348 tool: tool.clone(),
349 landed: landed.clone(),
350 available,
351 });
352 }
353 }
354 }
355 Ok(observed)
356}
357
358fn render_human(
360 out: Output,
361 args: &StatusArgs,
362 manifest: &Manifest,
363 alignment: Alignment,
364 observed: &Observed,
365) {
366 out.result_line(format!(
367 "release-kit {} ({}, {}, {} workflow, {} style{}) at {}",
368 manifest.rk_version,
369 manifest.tech,
370 manifest.forge,
371 manifest.parameters.workflow.as_str(),
372 manifest
373 .parameters
374 .style
375 .map_or("unrecorded", manifest::Style::as_str),
376 if manifest.parameters.nix { ", nix" } else { "" },
377 args.target
378 ));
379 match alignment {
380 Alignment::BinaryNewer => out.result_line(format!(
381 "binary {} is newer; run 'rk upgrade'",
382 env!("CARGO_PKG_VERSION")
383 )),
384 Alignment::TargetNewer => out.result_line(format!(
385 "binary {} is older than this landing; install the matching rk",
386 env!("CARGO_PKG_VERSION")
387 )),
388 Alignment::Aligned => {}
389 }
390 for path in &observed.drift_rendered {
391 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
392 }
393 for path in &observed.parameter_drift {
394 out.result_line(format!(
395 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
396 ));
397 }
398 for path in &observed.drift_seeded {
399 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
400 }
401 for path in &observed.missing {
402 out.result_line(format!("MISSING {path}"));
403 }
404 for pin in &observed.stale {
405 out.result_line(format!(
406 "STALE {} {} landed, {} in this binary",
407 pin.tool, pin.landed, pin.available
408 ));
409 }
410 for (path, line, text) in &observed.sentinels {
411 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
412 }
413 for failure in &observed.invariants {
414 out.result_line(format!(
415 "INVARIANT {} ({}): {}",
416 failure.destination, failure.code, failure.reason
417 ));
418 }
419 let mut next = Vec::new();
420 for failure in &observed.invariants {
421 next.push(format!("{}: {}", failure.destination, failure.remediation));
422 }
423 if alignment == Alignment::BinaryNewer {
424 next.push(format!(
425 "rk upgrade --target {} takes this landing to {}",
426 args.target,
427 env!("CARGO_PKG_VERSION")
428 ));
429 }
430 next.push(format!(
431 "rk status --check --target {} exits 1 on a violation",
432 args.target
433 ));
434 out.next(&next);
435}
436
437#[cfg(test)]
438mod tests {
439 #![allow(clippy::expect_used)]
440
441 use super::{Drift, InvariantFailure, Report, StalePin};
442
443 #[test]
446 fn the_status_report_schema_snapshot_holds() {
447 let landed = Report {
448 schema: "rk.status/5",
449 landed: true,
450 tech: Some("rust".into()),
451 forge: Some("github".into()),
452 workflow: Some("worktree"),
453 style: Some("trunk"),
454 nix: Some(true),
455 rk_version: Some("0.1.0".into()),
456 binary_version: Some("0.2.0"),
457 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
458 drift: Some(Drift {
459 rendered: 0,
460 seeded: 1,
461 }),
462 missing: Some(vec![]),
463 stale_pins: Some(vec![StalePin {
464 tool: "release-plz".into(),
465 landed: "0.3.160".into(),
466 available: "0.3.170".into(),
467 }]),
468 sentinels: Some(1),
469 invariant_failures: Some(vec![InvariantFailure {
470 code: "attestations-disabled",
471 destination: "dist-workspace.toml".into(),
472 reason: "github-attestations is not effectively true".into(),
473 remediation: "set github-attestations = true in [dist]",
474 }]),
475 violations: None,
476 };
477 assert_eq!(
478 serde_json::to_string(&landed).expect("a report serializes"),
479 r#"{"schema":"rk.status/5","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,"invariant_failures":[{"code":"attestations-disabled","destination":"dist-workspace.toml","reason":"github-attestations is not effectively true","remediation":"set github-attestations = true in [dist]"}]}"#
480 );
481 let absent = Report {
482 landed: false,
483 tech: None,
484 forge: None,
485 workflow: None,
486 style: None,
487 nix: None,
488 rk_version: None,
489 binary_version: None,
490 alignment: None,
491 drift: None,
492 missing: None,
493 stale_pins: None,
494 sentinels: None,
495 invariant_failures: None,
496 violations: None,
497 ..landed
498 };
499 assert_eq!(
500 serde_json::to_string(&absent).expect("a report serializes"),
501 r#"{"schema":"rk.status/5","landed":false}"#,
502 "an absent landing reports one field a caller can branch on"
503 );
504 }
505}