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, Entry, 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")]
84 record_drift: Option<usize>,
85 #[serde(skip_serializing_if = "Option::is_none")]
88 invariant_failures: Option<Vec<InvariantFailure>>,
89 #[serde(skip_serializing_if = "Option::is_none")]
95 pending: Option<usize>,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 violations: Option<Vec<String>>,
99}
100
101struct Observed {
103 drift_rendered: Vec<String>,
104 drift_seeded: Vec<String>,
105 parameter_drift: Vec<String>,
108 record_drift: Vec<String>,
113 missing: Vec<String>,
114 stale: Vec<StalePin>,
115 sentinels: Vec<(String, usize, String)>,
116 invariants: Vec<InvariantFailure>,
117 pending: Option<Vec<String>>,
120}
121
122pub fn run(args: &StatusArgs) -> Result<(), RkError> {
131 let out = Output::new(args.json);
132 if !args.target.is_dir() {
133 return Err(RkError::missing(
134 Diagnostic::new(
135 Reason::TargetNotFound,
136 format!("target {} is not a directory", args.target),
137 )
138 .expected("an existing repository to report on"),
139 ));
140 }
141 let Some(manifest) = manifest::load(&args.target)? else {
142 out.result_line(format!("no landing at {}", args.target));
143 out.next(&[
144 format!(
145 "rk init --tech <tech> --target {} lands the workflow",
146 args.target
147 ),
148 format!(
149 "rk adopt --target {} records a landing made before the record existed",
150 args.target
151 ),
152 ]);
153 out.emit(&Report {
154 schema: "rk.status/7",
155 landed: false,
156 tech: None,
157 forge: None,
158 workflow: None,
159 style: None,
160 nix: None,
161 rk_version: None,
162 binary_version: None,
163 alignment: None,
164 drift: None,
165 missing: None,
166 stale_pins: None,
167 sentinels: None,
168 record_drift: None,
169 invariant_failures: None,
170 pending: None,
171 violations: args.check.then(|| vec!["no landing".to_owned()]),
172 })?;
173 if args.check {
174 return Err(RkError::check_failed(
175 Diagnostic::new(
176 Reason::StateDrift,
177 format!("no landing at {}, and --check requires one", args.target),
178 )
179 .expected("a target carrying .release-kit/manifest.json")
180 .action("rk init lands the workflow; rk adopt records an existing landing"),
181 ));
182 }
183 return Ok(());
184 };
185
186 let observed = observe(args, &manifest)?;
187 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
188 render_human(out, args, &manifest, alignment, &observed);
189
190 let violations = violations_of(&observed);
191 out.emit(&Report {
192 schema: "rk.status/7",
193 landed: true,
194 tech: Some(manifest.tech),
195 forge: Some(manifest.forge),
196 workflow: Some(manifest.parameters.workflow.as_str()),
197 style: manifest.parameters.style.map(manifest::Style::as_str),
198 nix: Some(manifest.parameters.nix),
199 rk_version: Some(manifest.rk_version),
200 binary_version: Some(env!("CARGO_PKG_VERSION")),
201 alignment: Some(alignment),
202 drift: Some(Drift {
203 rendered: observed.drift_rendered.len() + observed.parameter_drift.len(),
204 seeded: observed.drift_seeded.len(),
205 }),
206 record_drift: Some(observed.record_drift.len()),
207 missing: Some(observed.missing.clone()),
208 stale_pins: Some(observed.stale),
209 sentinels: Some(observed.sentinels.len()),
210 invariant_failures: Some(observed.invariants),
211 pending: observed.pending.as_ref().map(Vec::len),
212 violations: args.check.then(|| violations.clone()),
213 })?;
214
215 if args.check && !violations.is_empty() {
216 return Err(RkError::check_failed(
217 Diagnostic::new(
218 Reason::StateDrift,
219 format!(
220 "the landing is not clean: {} violation{}",
221 violations.len(),
222 if violations.len() == 1 { "" } else { "s" }
223 ),
224 )
225 .expected(
226 "no rendered drift, no missing recorded file, no unresolved sentinel, no invariant failure",
227 ),
228 ));
229 }
230 Ok(())
231}
232
233fn violations_of(observed: &Observed) -> Vec<String> {
237 observed
238 .drift_rendered
239 .iter()
240 .map(|path| format!("rendered drift: {path}"))
241 .chain(
242 observed
243 .parameter_drift
244 .iter()
245 .map(|path| format!("parameter drift: {path}")),
246 )
247 .chain(
248 observed
249 .record_drift
250 .iter()
251 .map(|reason| format!("record drift: {reason}")),
252 )
253 .chain(
254 observed
255 .missing
256 .iter()
257 .map(|path| format!("missing: {path}")),
258 )
259 .chain(
260 observed
261 .sentinels
262 .iter()
263 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
264 )
265 .chain(
266 observed
267 .invariants
268 .iter()
269 .map(|failure| format!("invariant: {}: {}", failure.destination, failure.code)),
270 )
271 .collect()
272}
273
274fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
277 let mut observed = Observed {
278 drift_rendered: Vec::new(),
279 drift_seeded: Vec::new(),
280 parameter_drift: Vec::new(),
281 record_drift: Vec::new(),
282 missing: Vec::new(),
283 stale: Vec::new(),
284 sentinels: Vec::new(),
285 invariants: Vec::new(),
286 pending: None,
287 };
288 for file in &manifest.files {
289 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
290 observed.missing.push(file.destination.clone());
291 continue;
292 };
293 if Digest::of(&bytes) != file.sha256 {
294 match file.kind {
295 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
296 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
297 Kind::State => {}
298 }
299 }
300 observed.invariants.extend(invariants::failures(
301 &manifest.tech,
302 &manifest.forge,
303 &file.destination,
304 &bytes,
305 ));
306 let text = String::from_utf8_lossy(&bytes);
307 for (idx, line) in text.lines().enumerate() {
308 if line.contains(embedded::SENTINEL) {
309 observed.sentinels.push((
310 file.destination.clone(),
311 idx + 1,
312 line.trim().to_owned(),
313 ));
314 }
315 }
316 if file.destination == landing::HOOKS_DESTINATION
320 && !observed.drift_rendered.contains(&file.destination)
321 && landing::hooks_file_defect(&args.target)?.is_some()
322 {
323 observed.drift_rendered.push(file.destination.clone());
324 }
325 }
326 observed.invariants.extend(invariants::target_failures(
331 &manifest.tech,
332 &manifest.forge,
333 &args.target,
334 ));
335 let same_payload = manifest.payload_sha256 == crate::commands::payload::report().payload_sha256;
336 let projected = match project(args, manifest) {
342 Ok(entries) => Some(entries),
343 Err(err) if same_payload => return Err(err),
344 Err(_) => None,
345 };
346 if same_payload {
347 observe_parameter_drift(manifest, &mut observed);
348 if let Some(entries) = projected.as_deref() {
349 observe_record_set(manifest, entries, &mut observed.record_drift);
350 }
351 }
352 observed.pending = projected
357 .as_deref()
358 .map(|entries| pending_of(manifest, entries));
359 for (tool, landed) in &manifest.pins {
363 if let Some(available) = registry::version_of(tool) {
364 if manifest::version_is_newer(&available, landed) {
365 observed.stale.push(StalePin {
366 tool: tool.clone(),
367 landed: landed.clone(),
368 available,
369 });
370 }
371 }
372 }
373 Ok(observed)
374}
375
376fn observe_parameter_drift(manifest: &Manifest, observed: &mut Observed) {
388 for (destination, template) in [
389 (
390 landing::AGENTS_DESTINATION,
391 landing::routing_block(manifest.parameters.workflow),
392 ),
393 (
394 landing::HOOKS_DESTINATION,
395 landing::hooks_block(manifest.parameters.workflow),
396 ),
397 ] {
398 let Some(record) = manifest.file(destination) else {
399 continue;
400 };
401 if observed
402 .drift_rendered
403 .iter()
404 .any(|path| path == destination)
405 || observed.missing.iter().any(|path| path == destination)
406 {
407 continue;
408 }
409 let candidate = landing::render(
410 template.as_bytes(),
411 &manifest.parameters.repo,
412 manifest.parameters.style,
413 );
414 if Digest::of(&candidate) != record.sha256 {
415 observed
416 .parameter_drift
417 .push(format!("{destination} (parameters.workflow)"));
418 }
419 }
420}
421
422fn project(args: &StatusArgs, manifest: &Manifest) -> Result<Vec<Entry>, RkError> {
426 let mut projected = landing::projection(
427 &manifest.tech,
428 &manifest.forge,
429 &manifest.parameters.repo,
430 manifest.parameters.workflow,
431 manifest.parameters.style,
432 manifest.parameters.nix,
433 )?;
434 landing::withhold_nix(
435 &args.target,
436 manifest.parameters.nix,
437 Some(manifest),
438 &mut projected,
439 )?;
440 Ok(projected)
441}
442
443fn pending_of(manifest: &Manifest, projected: &[Entry]) -> Vec<String> {
452 let mut pending = Vec::new();
453 for entry in projected {
454 let changed = manifest.file(&entry.destination).is_none_or(|record| {
455 record.kind != entry.kind
456 || (entry.kind == Kind::Rendered && record.sha256 != Digest::of(&entry.rendered))
457 });
458 if changed {
459 pending.push(entry.destination.clone());
460 }
461 }
462 for file in &manifest.files {
463 if !projected
464 .iter()
465 .any(|entry| entry.destination == file.destination)
466 {
467 pending.push(file.destination.clone());
468 }
469 }
470 pending.sort();
471 pending.dedup();
472 pending
473}
474
475fn observe_record_set(manifest: &Manifest, projected: &[Entry], record_drift: &mut Vec<String>) {
485 for entry in projected {
486 if manifest.file(&entry.destination).is_none() {
487 record_drift.push(format!(
488 "the recorded parameters project {}, which the record does not name",
489 entry.destination
490 ));
491 }
492 }
493 for file in &manifest.files {
494 if !projected
495 .iter()
496 .any(|entry| entry.destination == file.destination)
497 {
498 record_drift.push(format!(
499 "the record names {}, which the recorded parameters do not project",
500 file.destination
501 ));
502 }
503 }
504}
505
506fn render_human(
508 out: Output,
509 args: &StatusArgs,
510 manifest: &Manifest,
511 alignment: Alignment,
512 observed: &Observed,
513) {
514 out.result_line(format!(
515 "release-kit {} ({}, {}, {} workflow, {} style{}) at {}",
516 manifest.rk_version,
517 manifest.tech,
518 manifest.forge,
519 manifest.parameters.workflow.as_str(),
520 manifest
521 .parameters
522 .style
523 .map_or("unrecorded", manifest::Style::as_str),
524 if manifest.parameters.nix { ", nix" } else { "" },
525 args.target
526 ));
527 if alignment == Alignment::TargetNewer {
528 out.result_line(format!(
529 "binary {} is older than this landing; install the matching rk",
530 env!("CARGO_PKG_VERSION")
531 ));
532 }
533 match observed.pending.as_deref() {
534 None => out.result_line(format!(
535 "this binary carries no {}/{} payload, so what an upgrade would change is unknown",
536 manifest.tech, manifest.forge
537 )),
538 Some(paths) => {
539 for path in paths {
540 out.result_line(format!("PENDING {path} (this payload would change it)"));
541 }
542 }
543 }
544 for path in &observed.drift_rendered {
545 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
546 }
547 for path in &observed.parameter_drift {
548 out.result_line(format!(
549 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
550 ));
551 }
552 for reason in &observed.record_drift {
553 out.result_line(format!("DRIFT record: {reason}"));
554 }
555 for path in &observed.drift_seeded {
556 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
557 }
558 for path in &observed.missing {
559 out.result_line(format!("MISSING {path}"));
560 }
561 for pin in &observed.stale {
562 out.result_line(format!(
563 "STALE {} {} landed, {} in this binary",
564 pin.tool, pin.landed, pin.available
565 ));
566 }
567 for (path, line, text) in &observed.sentinels {
568 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
569 }
570 for failure in &observed.invariants {
571 out.result_line(format!(
572 "INVARIANT {} ({}): {}",
573 failure.destination, failure.code, failure.reason
574 ));
575 }
576 let mut next = Vec::new();
577 for failure in &observed.invariants {
578 next.push(format!("{}: {}", failure.destination, failure.remediation));
579 }
580 if !observed.record_drift.is_empty() {
581 next.push(format!(
582 "rk upgrade --target {} reconciles the record with its parameters",
583 args.target
584 ));
585 }
586 if observed
587 .pending
588 .as_deref()
589 .is_none_or(|paths| !paths.is_empty())
590 {
591 next.push(format!(
592 "rk upgrade --target {} takes this landing to {}",
593 args.target,
594 env!("CARGO_PKG_VERSION")
595 ));
596 }
597 next.push(format!(
598 "rk status --check --target {} exits 1 on a violation",
599 args.target
600 ));
601 out.next(&next);
602}
603
604#[cfg(test)]
605mod tests {
606 #![allow(clippy::expect_used)]
607
608 use super::{Drift, InvariantFailure, Report, StalePin};
609
610 #[test]
613 fn the_status_report_schema_snapshot_holds() {
614 let landed = Report {
615 schema: "rk.status/7",
616 landed: true,
617 tech: Some("rust".into()),
618 forge: Some("github".into()),
619 workflow: Some("worktree"),
620 style: Some("trunk"),
621 nix: Some(true),
622 rk_version: Some("0.1.0".into()),
623 binary_version: Some("0.2.0"),
624 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
625 drift: Some(Drift {
626 rendered: 0,
627 seeded: 1,
628 }),
629 missing: Some(vec![]),
630 stale_pins: Some(vec![StalePin {
631 tool: "release-plz".into(),
632 landed: "0.3.160".into(),
633 available: "0.3.170".into(),
634 }]),
635 sentinels: Some(1),
636 record_drift: Some(0),
637 invariant_failures: Some(vec![InvariantFailure {
638 code: "attestations-disabled",
639 destination: "dist-workspace.toml".into(),
640 reason: "github-attestations is not effectively true".into(),
641 remediation: "set github-attestations = true in [dist]",
642 }]),
643 pending: Some(2),
644 violations: None,
645 };
646 assert_eq!(
647 serde_json::to_string(&landed).expect("a report serializes"),
648 r#"{"schema":"rk.status/7","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]"}],"pending":2}"#
649 );
650 let absent = Report {
651 landed: false,
652 tech: None,
653 forge: None,
654 workflow: None,
655 style: None,
656 nix: None,
657 rk_version: None,
658 binary_version: None,
659 alignment: None,
660 drift: None,
661 missing: None,
662 stale_pins: None,
663 sentinels: None,
664 record_drift: None,
665 invariant_failures: None,
666 pending: None,
667 violations: None,
668 ..landed
669 };
670 assert_eq!(
671 serde_json::to_string(&absent).expect("a report serializes"),
672 r#"{"schema":"rk.status/7","landed":false}"#,
673 "an absent landing reports one field a caller can branch on"
674 );
675 }
676}