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")]
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")]
91 violations: Option<Vec<String>>,
92}
93
94struct Observed {
96 drift_rendered: Vec<String>,
97 drift_seeded: Vec<String>,
98 parameter_drift: Vec<String>,
101 record_drift: Vec<String>,
106 missing: Vec<String>,
107 stale: Vec<StalePin>,
108 sentinels: Vec<(String, usize, String)>,
109 invariants: Vec<InvariantFailure>,
110}
111
112pub 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
221fn 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
262fn 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 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 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 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
346fn 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.style,
383 );
384 if Digest::of(&candidate) != record.sha256 {
385 observed
386 .parameter_drift
387 .push(format!("{destination} (parameters.workflow)"));
388 }
389 }
390}
391
392fn observe_record_set(
402 args: &StatusArgs,
403 manifest: &Manifest,
404 record_drift: &mut Vec<String>,
405) -> Result<(), RkError> {
406 let mut projected = landing::projection(
407 &manifest.tech,
408 &manifest.forge,
409 &manifest.parameters.repo,
410 manifest.parameters.workflow,
411 manifest.parameters.style,
412 manifest.parameters.nix,
413 )?;
414 landing::withhold_nix(
415 &args.target,
416 manifest.parameters.nix,
417 Some(manifest),
418 &mut projected,
419 )?;
420 for entry in &projected {
421 if manifest.file(&entry.destination).is_none() {
422 record_drift.push(format!(
423 "the recorded parameters project {}, which the record does not name",
424 entry.destination
425 ));
426 }
427 }
428 for file in &manifest.files {
429 if !projected
430 .iter()
431 .any(|entry| entry.destination == file.destination)
432 {
433 record_drift.push(format!(
434 "the record names {}, which the recorded parameters do not project",
435 file.destination
436 ));
437 }
438 }
439 Ok(())
440}
441
442fn render_human(
444 out: Output,
445 args: &StatusArgs,
446 manifest: &Manifest,
447 alignment: Alignment,
448 observed: &Observed,
449) {
450 out.result_line(format!(
451 "release-kit {} ({}, {}, {} workflow, {} style{}) at {}",
452 manifest.rk_version,
453 manifest.tech,
454 manifest.forge,
455 manifest.parameters.workflow.as_str(),
456 manifest
457 .parameters
458 .style
459 .map_or("unrecorded", manifest::Style::as_str),
460 if manifest.parameters.nix { ", nix" } else { "" },
461 args.target
462 ));
463 match alignment {
464 Alignment::BinaryNewer => out.result_line(format!(
465 "binary {} is newer; run 'rk upgrade'",
466 env!("CARGO_PKG_VERSION")
467 )),
468 Alignment::TargetNewer => out.result_line(format!(
469 "binary {} is older than this landing; install the matching rk",
470 env!("CARGO_PKG_VERSION")
471 )),
472 Alignment::Aligned => {}
473 }
474 for path in &observed.drift_rendered {
475 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
476 }
477 for path in &observed.parameter_drift {
478 out.result_line(format!(
479 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
480 ));
481 }
482 for reason in &observed.record_drift {
483 out.result_line(format!("DRIFT record: {reason}"));
484 }
485 for path in &observed.drift_seeded {
486 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
487 }
488 for path in &observed.missing {
489 out.result_line(format!("MISSING {path}"));
490 }
491 for pin in &observed.stale {
492 out.result_line(format!(
493 "STALE {} {} landed, {} in this binary",
494 pin.tool, pin.landed, pin.available
495 ));
496 }
497 for (path, line, text) in &observed.sentinels {
498 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
499 }
500 for failure in &observed.invariants {
501 out.result_line(format!(
502 "INVARIANT {} ({}): {}",
503 failure.destination, failure.code, failure.reason
504 ));
505 }
506 let mut next = Vec::new();
507 for failure in &observed.invariants {
508 next.push(format!("{}: {}", failure.destination, failure.remediation));
509 }
510 if !observed.record_drift.is_empty() {
511 next.push(format!(
512 "rk upgrade --target {} reconciles the record with its parameters",
513 args.target
514 ));
515 }
516 if alignment == Alignment::BinaryNewer {
517 next.push(format!(
518 "rk upgrade --target {} takes this landing to {}",
519 args.target,
520 env!("CARGO_PKG_VERSION")
521 ));
522 }
523 next.push(format!(
524 "rk status --check --target {} exits 1 on a violation",
525 args.target
526 ));
527 out.next(&next);
528}
529
530#[cfg(test)]
531mod tests {
532 #![allow(clippy::expect_used)]
533
534 use super::{Drift, InvariantFailure, Report, StalePin};
535
536 #[test]
539 fn the_status_report_schema_snapshot_holds() {
540 let landed = Report {
541 schema: "rk.status/6",
542 landed: true,
543 tech: Some("rust".into()),
544 forge: Some("github".into()),
545 workflow: Some("worktree"),
546 style: Some("trunk"),
547 nix: Some(true),
548 rk_version: Some("0.1.0".into()),
549 binary_version: Some("0.2.0"),
550 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
551 drift: Some(Drift {
552 rendered: 0,
553 seeded: 1,
554 }),
555 missing: Some(vec![]),
556 stale_pins: Some(vec![StalePin {
557 tool: "release-plz".into(),
558 landed: "0.3.160".into(),
559 available: "0.3.170".into(),
560 }]),
561 sentinels: Some(1),
562 record_drift: Some(0),
563 invariant_failures: Some(vec![InvariantFailure {
564 code: "attestations-disabled",
565 destination: "dist-workspace.toml".into(),
566 reason: "github-attestations is not effectively true".into(),
567 remediation: "set github-attestations = true in [dist]",
568 }]),
569 violations: None,
570 };
571 assert_eq!(
572 serde_json::to_string(&landed).expect("a report serializes"),
573 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]"}]}"#
574 );
575 let absent = Report {
576 landed: false,
577 tech: None,
578 forge: None,
579 workflow: None,
580 style: None,
581 nix: None,
582 rk_version: None,
583 binary_version: None,
584 alignment: None,
585 drift: None,
586 missing: None,
587 stale_pins: None,
588 sentinels: None,
589 record_drift: None,
590 invariant_failures: None,
591 violations: None,
592 ..landed
593 };
594 assert_eq!(
595 serde_json::to_string(&absent).expect("a report serializes"),
596 r#"{"schema":"rk.status/6","landed":false}"#,
597 "an absent landing reports one field a caller can branch on"
598 );
599 }
600}