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 let same_payload = manifest.payload_sha256 == crate::commands::payload::report().payload_sha256;
324 for (destination, template) in [
325 (
326 landing::AGENTS_DESTINATION,
327 landing::routing_block(manifest.parameters.workflow),
328 ),
329 (
330 landing::HOOKS_DESTINATION,
331 landing::hooks_block(manifest.parameters.workflow),
332 ),
333 ] {
334 if !same_payload {
335 break;
336 }
337 let Some(record) = manifest.file(destination) else {
338 continue;
339 };
340 if observed
341 .drift_rendered
342 .iter()
343 .any(|path| path == destination)
344 || observed.missing.iter().any(|path| path == destination)
345 {
346 continue;
347 }
348 let candidate = landing::render(
349 template.as_bytes(),
350 &manifest.parameters.repo,
351 &manifest.parameters.scopes,
352 manifest.parameters.style,
353 );
354 if Digest::of(&candidate) != record.sha256 {
355 observed
356 .parameter_drift
357 .push(format!("{destination} (parameters.workflow)"));
358 }
359 }
360 if same_payload {
361 observe_record_set(args, manifest, &mut observed.record_drift)?;
362 }
363 for (tool, landed) in &manifest.pins {
367 if let Some(available) = registry::version_of(tool) {
368 if manifest::version_is_newer(&available, landed) {
369 observed.stale.push(StalePin {
370 tool: tool.clone(),
371 landed: landed.clone(),
372 available,
373 });
374 }
375 }
376 }
377 Ok(observed)
378}
379
380fn observe_record_set(
390 args: &StatusArgs,
391 manifest: &Manifest,
392 record_drift: &mut Vec<String>,
393) -> Result<(), RkError> {
394 let mut projected = landing::projection(
395 &manifest.tech,
396 &manifest.forge,
397 &manifest.parameters.repo,
398 &manifest.parameters.scopes,
399 manifest.parameters.workflow,
400 manifest.parameters.style,
401 manifest.parameters.nix,
402 )?;
403 landing::withhold_nix(
404 &args.target,
405 manifest.parameters.nix,
406 Some(manifest),
407 &mut projected,
408 )?;
409 for entry in &projected {
410 if manifest.file(&entry.destination).is_none() {
411 record_drift.push(format!(
412 "the recorded parameters project {}, which the record does not name",
413 entry.destination
414 ));
415 }
416 }
417 for file in &manifest.files {
418 if !projected
419 .iter()
420 .any(|entry| entry.destination == file.destination)
421 {
422 record_drift.push(format!(
423 "the record names {}, which the recorded parameters do not project",
424 file.destination
425 ));
426 }
427 }
428 Ok(())
429}
430
431fn render_human(
433 out: Output,
434 args: &StatusArgs,
435 manifest: &Manifest,
436 alignment: Alignment,
437 observed: &Observed,
438) {
439 out.result_line(format!(
440 "release-kit {} ({}, {}, {} workflow, {} style{}) at {}",
441 manifest.rk_version,
442 manifest.tech,
443 manifest.forge,
444 manifest.parameters.workflow.as_str(),
445 manifest
446 .parameters
447 .style
448 .map_or("unrecorded", manifest::Style::as_str),
449 if manifest.parameters.nix { ", nix" } else { "" },
450 args.target
451 ));
452 match alignment {
453 Alignment::BinaryNewer => out.result_line(format!(
454 "binary {} is newer; run 'rk upgrade'",
455 env!("CARGO_PKG_VERSION")
456 )),
457 Alignment::TargetNewer => out.result_line(format!(
458 "binary {} is older than this landing; install the matching rk",
459 env!("CARGO_PKG_VERSION")
460 )),
461 Alignment::Aligned => {}
462 }
463 for path in &observed.drift_rendered {
464 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
465 }
466 for path in &observed.parameter_drift {
467 out.result_line(format!(
468 "DRIFT {path}: the recorded parameters do not render the recorded bytes"
469 ));
470 }
471 for reason in &observed.record_drift {
472 out.result_line(format!("DRIFT record: {reason}"));
473 }
474 for path in &observed.drift_seeded {
475 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
476 }
477 for path in &observed.missing {
478 out.result_line(format!("MISSING {path}"));
479 }
480 for pin in &observed.stale {
481 out.result_line(format!(
482 "STALE {} {} landed, {} in this binary",
483 pin.tool, pin.landed, pin.available
484 ));
485 }
486 for (path, line, text) in &observed.sentinels {
487 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
488 }
489 for failure in &observed.invariants {
490 out.result_line(format!(
491 "INVARIANT {} ({}): {}",
492 failure.destination, failure.code, failure.reason
493 ));
494 }
495 let mut next = Vec::new();
496 for failure in &observed.invariants {
497 next.push(format!("{}: {}", failure.destination, failure.remediation));
498 }
499 if !observed.record_drift.is_empty() {
500 next.push(format!(
501 "rk upgrade --target {} reconciles the record with its parameters",
502 args.target
503 ));
504 }
505 if alignment == Alignment::BinaryNewer {
506 next.push(format!(
507 "rk upgrade --target {} takes this landing to {}",
508 args.target,
509 env!("CARGO_PKG_VERSION")
510 ));
511 }
512 next.push(format!(
513 "rk status --check --target {} exits 1 on a violation",
514 args.target
515 ));
516 out.next(&next);
517}
518
519#[cfg(test)]
520mod tests {
521 #![allow(clippy::expect_used)]
522
523 use super::{Drift, InvariantFailure, Report, StalePin};
524
525 #[test]
528 fn the_status_report_schema_snapshot_holds() {
529 let landed = Report {
530 schema: "rk.status/6",
531 landed: true,
532 tech: Some("rust".into()),
533 forge: Some("github".into()),
534 workflow: Some("worktree"),
535 style: Some("trunk"),
536 nix: Some(true),
537 rk_version: Some("0.1.0".into()),
538 binary_version: Some("0.2.0"),
539 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
540 drift: Some(Drift {
541 rendered: 0,
542 seeded: 1,
543 }),
544 missing: Some(vec![]),
545 stale_pins: Some(vec![StalePin {
546 tool: "release-plz".into(),
547 landed: "0.3.160".into(),
548 available: "0.3.170".into(),
549 }]),
550 sentinels: Some(1),
551 record_drift: Some(0),
552 invariant_failures: Some(vec![InvariantFailure {
553 code: "attestations-disabled",
554 destination: "dist-workspace.toml".into(),
555 reason: "github-attestations is not effectively true".into(),
556 remediation: "set github-attestations = true in [dist]",
557 }]),
558 violations: None,
559 };
560 assert_eq!(
561 serde_json::to_string(&landed).expect("a report serializes"),
562 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]"}]}"#
563 );
564 let absent = Report {
565 landed: false,
566 tech: None,
567 forge: None,
568 workflow: None,
569 style: None,
570 nix: None,
571 rk_version: None,
572 binary_version: None,
573 alignment: None,
574 drift: None,
575 missing: None,
576 stale_pins: None,
577 sentinels: None,
578 record_drift: None,
579 invariant_failures: None,
580 violations: None,
581 ..landed
582 };
583 assert_eq!(
584 serde_json::to_string(&absent).expect("a report serializes"),
585 r#"{"schema":"rk.status/6","landed":false}"#,
586 "an absent landing reports one field a caller can branch on"
587 );
588 }
589}