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::manifest::{self, Alignment, Manifest};
18use crate::landing::{self, Kind};
19use crate::output::Output;
20use crate::{embedded, registry};
21
22#[derive(Debug, Serialize)]
24struct Drift {
25 rendered: usize,
27 seeded: usize,
29}
30
31#[derive(Debug, Serialize)]
33struct StalePin {
34 tool: String,
36 landed: String,
38 available: String,
40}
41
42#[derive(Debug, Serialize)]
44struct Report {
45 schema: &'static str,
47 landed: bool,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 tech: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 forge: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 rk_version: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 binary_version: Option<&'static str>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 alignment: Option<Alignment>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 drift: Option<Drift>,
61 #[serde(skip_serializing_if = "Option::is_none")]
63 missing: Option<Vec<String>>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 stale_pins: Option<Vec<StalePin>>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 sentinels: Option<usize>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 violations: Option<Vec<String>>,
72}
73
74struct Observed {
76 drift_rendered: Vec<String>,
77 drift_seeded: Vec<String>,
78 missing: Vec<String>,
79 stale: Vec<StalePin>,
80 sentinels: Vec<(String, usize, String)>,
81}
82
83pub fn run(args: &StatusArgs) -> Result<(), RkError> {
92 let out = Output::new(args.json);
93 if !args.target.is_dir() {
94 return Err(RkError::missing(
95 Diagnostic::new(
96 Reason::TargetNotFound,
97 format!("target {} is not a directory", args.target),
98 )
99 .expected("an existing repository to report on"),
100 ));
101 }
102 let Some(manifest) = manifest::load(&args.target)? else {
103 out.result_line(format!("no landing at {}", args.target));
104 out.next(&[
105 format!(
106 "rk init --tech <tech> --target {} lands the workflow",
107 args.target
108 ),
109 format!(
110 "rk adopt --target {} records a landing made before the record existed",
111 args.target
112 ),
113 ]);
114 out.emit(&Report {
115 schema: "rk.status/1",
116 landed: false,
117 tech: None,
118 forge: None,
119 rk_version: None,
120 binary_version: None,
121 alignment: None,
122 drift: None,
123 missing: None,
124 stale_pins: None,
125 sentinels: None,
126 violations: args.check.then(|| vec!["no landing".to_owned()]),
127 })?;
128 if args.check {
129 return Err(RkError::check_failed(
130 Diagnostic::new(
131 Reason::StateDrift,
132 format!("no landing at {}, and --check requires one", args.target),
133 )
134 .expected("a target carrying .release-kit/manifest.json")
135 .action("rk init lands the workflow; rk adopt records an existing landing"),
136 ));
137 }
138 return Ok(());
139 };
140
141 let observed = observe(args, &manifest)?;
142 let alignment = manifest::alignment(&manifest.rk_version, env!("CARGO_PKG_VERSION"));
143 render_human(out, args, &manifest, alignment, &observed);
144
145 let violations: Vec<String> = observed
146 .drift_rendered
147 .iter()
148 .map(|path| format!("rendered drift: {path}"))
149 .chain(
150 observed
151 .missing
152 .iter()
153 .map(|path| format!("missing: {path}")),
154 )
155 .chain(
156 observed
157 .sentinels
158 .iter()
159 .map(|(path, line, _)| format!("sentinel: {path}:{line}")),
160 )
161 .collect();
162 out.emit(&Report {
163 schema: "rk.status/1",
164 landed: true,
165 tech: Some(manifest.tech),
166 forge: Some(manifest.forge),
167 rk_version: Some(manifest.rk_version),
168 binary_version: Some(env!("CARGO_PKG_VERSION")),
169 alignment: Some(alignment),
170 drift: Some(Drift {
171 rendered: observed.drift_rendered.len(),
172 seeded: observed.drift_seeded.len(),
173 }),
174 missing: Some(observed.missing.clone()),
175 stale_pins: Some(observed.stale),
176 sentinels: Some(observed.sentinels.len()),
177 violations: args.check.then(|| violations.clone()),
178 })?;
179
180 if args.check && !violations.is_empty() {
181 return Err(RkError::check_failed(
182 Diagnostic::new(
183 Reason::StateDrift,
184 format!(
185 "the landing is not clean: {} violation{}",
186 violations.len(),
187 if violations.len() == 1 { "" } else { "s" }
188 ),
189 )
190 .expected("no rendered drift, no missing recorded file, no unresolved sentinel"),
191 ));
192 }
193 Ok(())
194}
195
196fn observe(args: &StatusArgs, manifest: &Manifest) -> Result<Observed, RkError> {
199 let mut observed = Observed {
200 drift_rendered: Vec::new(),
201 drift_seeded: Vec::new(),
202 missing: Vec::new(),
203 stale: Vec::new(),
204 sentinels: Vec::new(),
205 };
206 for file in &manifest.files {
207 let Some(bytes) = landing::read_recorded(&args.target, &file.destination)? else {
208 observed.missing.push(file.destination.clone());
209 continue;
210 };
211 if Digest::of(&bytes) != file.sha256 {
212 match file.kind {
213 Kind::Rendered => observed.drift_rendered.push(file.destination.clone()),
214 Kind::Seeded => observed.drift_seeded.push(file.destination.clone()),
215 Kind::State => {}
216 }
217 }
218 let text = String::from_utf8_lossy(&bytes);
219 for (idx, line) in text.lines().enumerate() {
220 if line.contains(embedded::SENTINEL) {
221 observed.sentinels.push((
222 file.destination.clone(),
223 idx + 1,
224 line.trim().to_owned(),
225 ));
226 }
227 }
228 if file.destination == landing::HOOKS_DESTINATION
232 && !observed.drift_rendered.contains(&file.destination)
233 && landing::hooks_file_defect(&args.target)?.is_some()
234 {
235 observed.drift_rendered.push(file.destination.clone());
236 }
237 }
238 for (tool, landed) in &manifest.pins {
242 if let Some(available) = registry::version_of(tool) {
243 if manifest::version_is_newer(&available, landed) {
244 observed.stale.push(StalePin {
245 tool: tool.clone(),
246 landed: landed.clone(),
247 available,
248 });
249 }
250 }
251 }
252 Ok(observed)
253}
254
255fn render_human(
257 out: Output,
258 args: &StatusArgs,
259 manifest: &Manifest,
260 alignment: Alignment,
261 observed: &Observed,
262) {
263 out.result_line(format!(
264 "release-kit {} ({}, {}) at {}",
265 manifest.rk_version, manifest.tech, manifest.forge, args.target
266 ));
267 match alignment {
268 Alignment::BinaryNewer => out.result_line(format!(
269 "binary {} is newer; run 'rk upgrade'",
270 env!("CARGO_PKG_VERSION")
271 )),
272 Alignment::TargetNewer => out.result_line(format!(
273 "binary {} is older than this landing; install the matching rk",
274 env!("CARGO_PKG_VERSION")
275 )),
276 Alignment::Aligned => {}
277 }
278 for path in &observed.drift_rendered {
279 out.result_line(format!("DRIFT {path} (rendered, release-kit-owned)"));
280 }
281 for path in &observed.drift_seeded {
282 out.result_line(format!("DRIFT {path} (seeded, target-owned)"));
283 }
284 for path in &observed.missing {
285 out.result_line(format!("MISSING {path}"));
286 }
287 for pin in &observed.stale {
288 out.result_line(format!(
289 "STALE {} {} landed, {} in this binary",
290 pin.tool, pin.landed, pin.available
291 ));
292 }
293 for (path, line, text) in &observed.sentinels {
294 out.result_line(format!("SENTINEL {path}:{line}: {text}"));
295 }
296 let mut next = Vec::new();
297 if alignment == Alignment::BinaryNewer {
298 next.push(format!(
299 "rk upgrade --target {} takes this landing to {}",
300 args.target,
301 env!("CARGO_PKG_VERSION")
302 ));
303 }
304 next.push(format!(
305 "rk status --check --target {} exits 1 on a violation",
306 args.target
307 ));
308 out.next(&next);
309}
310
311#[cfg(test)]
312mod tests {
313 #![allow(clippy::expect_used)]
314
315 use super::{Drift, Report, StalePin};
316
317 #[test]
320 fn the_status_report_schema_snapshot_holds() {
321 let landed = Report {
322 schema: "rk.status/1",
323 landed: true,
324 tech: Some("rust".into()),
325 forge: Some("github".into()),
326 rk_version: Some("0.1.0".into()),
327 binary_version: Some("0.2.0"),
328 alignment: Some(crate::landing::manifest::Alignment::BinaryNewer),
329 drift: Some(Drift {
330 rendered: 0,
331 seeded: 1,
332 }),
333 missing: Some(vec![]),
334 stale_pins: Some(vec![StalePin {
335 tool: "release-plz".into(),
336 landed: "0.3.160".into(),
337 available: "0.3.170".into(),
338 }]),
339 sentinels: Some(1),
340 violations: None,
341 };
342 assert_eq!(
343 serde_json::to_string(&landed).expect("a report serializes"),
344 r#"{"schema":"rk.status/1","landed":true,"tech":"rust","forge":"github","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}"#
345 );
346 let absent = Report {
347 landed: false,
348 tech: None,
349 forge: None,
350 rk_version: None,
351 binary_version: None,
352 alignment: None,
353 drift: None,
354 missing: None,
355 stale_pins: None,
356 sentinels: None,
357 violations: None,
358 ..landed
359 };
360 assert_eq!(
361 serde_json::to_string(&absent).expect("a report serializes"),
362 r#"{"schema":"rk.status/1","landed":false}"#,
363 "an absent landing reports one field a caller can branch on"
364 );
365 }
366}