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")]
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 #[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 #[serde(skip_serializing_if = "Option::is_none")]
75 sentinels: Option<usize>,
76 #[serde(skip_serializing_if = "Option::is_none")]
79 invariant_failures: Option<Vec<InvariantFailure>>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 violations: Option<Vec<String>>,
83}
84
85struct Observed {
87 drift_rendered: Vec<String>,
88 drift_seeded: Vec<String>,
89 parameter_drift: Vec<String>,
92 missing: Vec<String>,
93 stale: Vec<StalePin>,
94 sentinels: Vec<(String, usize, String)>,
95 invariants: Vec<InvariantFailure>,
96}
97
98pub 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
203fn 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
238fn 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 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 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 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
352fn 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 #[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}