1use std::path::Path;
18use std::process::Command;
19
20use camino::Utf8Path;
21use serde::Serialize;
22
23use super::{ARTIFACTS_DIR, read};
24use crate::digest::Digest;
25use crate::error::RkError;
26use crate::landing;
27use crate::landing::apply::{self, Held};
28use crate::landing::manifest;
29
30pub const SCHEMA: &str = "rk.stage-compare/3";
32
33#[derive(Debug, Clone, Serialize)]
35pub struct Row {
36 pub path: String,
38 pub state: &'static str,
43 pub kind: &'static str,
45 pub provenance: Option<&'static str>,
49 pub vouched: Option<bool>,
52 pub edited_after_added: Option<bool>,
55 pub word: &'static str,
59 pub replaceable: bool,
63}
64
65#[derive(Debug, Clone, Serialize)]
67pub struct History {
68 pub available: bool,
70 pub reason: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize)]
76pub struct Report {
77 pub schema: &'static str,
79 pub stage_root: String,
81 pub target: String,
83 pub history: History,
85 pub rows: Vec<Row>,
87}
88
89#[allow(
96 clippy::too_many_lines,
97 reason = "one pass resolves the stage's answers, runs the production decision, and judges every row"
98)]
99pub fn compare(root: &Utf8Path, target: &Utf8Path) -> Result<Report, RkError> {
100 let canonical = std::fs::canonicalize(target.as_std_path())?;
101 let receipt = read::load(root, &canonical)?;
102 let held = Held::open(target)?;
103 let recorded = manifest::load(held.base())?;
104 let config = crate::config::load(held.base().as_std_path())?;
105 let purpose = super::purpose(target, config.as_ref(), recorded.is_some())?;
108 let params = landing::Params::resolve(
109 held.base(),
110 &read::inputs(&receipt.parameters),
111 config.as_ref(),
112 recorded.as_ref(),
113 purpose,
114 )?;
115 read::confirm(¶ms, &receipt, root)?;
116 let prepared = apply::prepare(&held, recorded.as_ref(), ¶ms, config.as_ref())?;
117 let history = match crate::assess::git_work_tree(target) {
118 Ok(true) => History {
119 available: true,
120 reason: None,
121 },
122 Ok(false) => History {
123 available: false,
124 reason: Some(
125 "the target is not a non-bare Git work tree, so the history half reads as missing"
126 .to_owned(),
127 ),
128 },
129 Err(error) => History {
130 available: false,
131 reason: Some(format!(
132 "git did not answer, so the history half reads as missing: {error}"
133 )),
134 },
135 };
136 let adopting = purpose == landing::Purpose::Adopt;
139 let refused = recorded.as_ref().is_some_and(|record| {
142 manifest::alignment(&record.rk_version, env!("CARGO_PKG_VERSION"))
143 == manifest::Alignment::TargetNewer
144 });
145 let word = |destination: &str| -> Result<(&'static str, bool), RkError> {
146 if adopting
147 && let Some(candidate) = prepared
148 .projection
149 .candidates
150 .iter()
151 .find(|candidate| candidate.destination == destination)
152 {
153 let judged = apply::judge_adoption(&held, candidate)?;
154 return Ok((judged.word(), judged.replaceable(candidate.kind)));
155 }
156 let replaceable = !refused
157 && prepared
158 .collisions
159 .iter()
160 .any(|collision| collision.path == destination && collision.replaceable);
161 Ok((
162 prepared
163 .decision(destination)
164 .map_or("collision", |decision| decision.action.as_str()),
165 replaceable,
166 ))
167 };
168 let mut rows = Vec::new();
169 for candidate in &receipt.candidates {
170 let region = candidate.placement == "region";
171 let artifact = std::fs::read(
172 root.join(ARTIFACTS_DIR)
173 .join(&candidate.destination)
174 .as_std_path(),
175 )?;
176 let expected = if region {
177 region_of(&candidate.destination, &artifact).unwrap_or(artifact)
178 } else {
179 artifact
180 };
181 let current = landing::read_recorded(held.base(), &candidate.destination)?;
182 let state = match ¤t {
183 None => "absent",
184 Some(bytes) if *bytes == expected => "equal",
185 Some(_) => "differs",
186 };
187 let record = recorded
188 .as_ref()
189 .and_then(|record| record.file(&candidate.destination));
190 rows.push(row(
191 &candidate.destination,
192 state,
193 if region {
194 "region"
195 } else {
196 candidate.kind.as_str()
197 },
198 record,
199 current.as_deref(),
200 &history,
201 target,
202 word(&candidate.destination)?,
203 ));
204 }
205 for destination in receipt.retired.iter().chain(&receipt.dropped) {
211 let record = recorded
212 .as_ref()
213 .and_then(|record| record.file(destination));
214 let kind = record.map_or("rendered", |record| match record.placement {
215 manifest::Placement::Region => "region",
216 manifest::Placement::Whole => record.kind.as_str(),
217 });
218 let Ok(current) = landing::read_recorded(held.base(), destination) else {
223 rows.push(Row {
224 path: destination.clone(),
225 state: "dropped",
226 kind,
227 provenance: Some("unknown"),
228 vouched: None,
229 edited_after_added: None,
230 word: prepared
231 .decision(destination)
232 .map_or("dropped", |decision| decision.action.as_str()),
233 replaceable: false,
234 });
235 continue;
236 };
237 let (state, word) = match prepared.decision(destination) {
238 Some(decision) if decision.action == apply::Action::Retired => ("retired", "retired"),
239 Some(decision) => ("dropped", decision.action.as_str()),
240 None if current.is_some() => ("dropped", "dropped"),
241 None if receipt.dropped.contains(destination) => ("equal", "dropped"),
242 None => ("equal", "retired"),
243 };
244 rows.push(row(
245 destination,
246 state,
247 kind,
248 record,
249 current.as_deref(),
250 &history,
251 target,
252 (word, false),
253 ));
254 }
255 Ok(Report {
256 schema: SCHEMA,
257 stage_root: receipt.stage_root,
258 target: receipt.target,
259 history,
260 rows,
261 })
262}
263
264#[allow(
266 clippy::too_many_arguments,
267 reason = "a row joins the stage, the record, the disk, and the history, each one argument"
268)]
269fn row(
270 path: &str,
271 state: &'static str,
272 kind: &'static str,
273 record: Option<&manifest::FileRecord>,
274 current: Option<&[u8]>,
275 history: &History,
276 target: &Utf8Path,
277 (word, replaceable): (&'static str, bool),
278) -> Row {
279 let vouched = record
280 .zip(current)
281 .map(|(record, bytes)| Digest::of(bytes) == record.sha256);
282 let commits = history
283 .available
284 .then(|| commits_touching(target, path))
285 .flatten();
286 let provenance = matches!(state, "differs" | "retired" | "dropped").then(|| {
287 let record_half = vouched == Some(true);
288 let history_half = commits.as_ref().is_some_and(|commits| commits > &0);
289 match (record_half, history_half) {
290 (true, true) => "attributed",
291 (true, false) | (false, true) => "reduced",
292 (false, false) => "unknown",
293 }
294 });
295 let edited_after_added = if record.is_none() && current.is_some() {
296 commits.and_then(|commits| match commits {
297 0 => None,
298 1 => edited_in_the_tree(target, path),
299 _ => Some(true),
300 })
301 } else {
302 None
303 };
304 Row {
305 path: path.to_owned(),
306 state,
307 kind,
308 provenance,
309 vouched,
310 edited_after_added,
311 word,
312 replaceable,
313 }
314}
315
316fn region_of(destination: &str, document: &[u8]) -> Option<Vec<u8>> {
319 let (begin, end) = landing::block_markers(destination)?;
320 landing::extract_block(&String::from_utf8_lossy(document), begin, end)
321 .map(|block| block.as_bytes().to_vec())
322}
323
324fn commits_touching(target: &Utf8Path, path: &str) -> Option<usize> {
326 let output = git(target)
327 .args(["log", "--format=%H", "--", path])
328 .output()
329 .ok()?;
330 output.status.success().then(|| {
331 String::from_utf8_lossy(&output.stdout)
332 .lines()
333 .filter(|line| !line.trim().is_empty())
334 .count()
335 })
336}
337
338fn edited_in_the_tree(target: &Utf8Path, path: &str) -> Option<bool> {
341 let status = git(target)
342 .args(["diff", "--quiet", "HEAD", "--", path])
343 .status()
344 .ok()?;
345 match status.code() {
346 Some(0) => Some(false),
347 Some(1) => Some(true),
348 _ => None,
349 }
350}
351
352fn git(target: &Utf8Path) -> Command {
355 let mut command = Command::new(crate::probes::git_bin());
356 for var in crate::maintenance::GIT_HOOK_VARS {
357 command.env_remove(var);
358 }
359 command
360 .env("LC_ALL", "C")
361 .arg("-C")
362 .arg(Path::new(target.as_str()));
363 command
364}
365
366#[must_use]
372pub fn rendered_equal(report: &Report) -> bool {
373 report
374 .rows
375 .iter()
376 .filter(|row| row.state != "dropped" && matches!(row.kind, "rendered" | "region"))
377 .all(|row| row.state == "equal")
378}
379
380#[cfg(test)]
381mod tests {
382 use super::{History, Report, Row};
383
384 #[test]
386 fn the_compare_report_schema_snapshot_holds() {
387 let report = Report {
388 schema: super::SCHEMA,
389 stage_root: "/state/stages/abc/0.9.0".into(),
390 target: "/srv/widget".into(),
391 history: History {
392 available: true,
393 reason: None,
394 },
395 rows: vec![
396 Row {
397 path: ".github/workflows/release-plz.yml".into(),
398 state: "differs",
399 kind: "rendered",
400 provenance: Some("reduced"),
401 vouched: Some(false),
402 edited_after_added: None,
403 word: "collision",
404 replaceable: true,
405 },
406 Row {
407 path: "AGENTS.md".into(),
408 state: "equal",
409 kind: "region",
410 provenance: None,
411 vouched: Some(true),
412 edited_after_added: None,
413 word: "replaced",
414 replaceable: false,
415 },
416 Row {
417 path: ".github/workflows/old-release.yml".into(),
418 state: "retired",
419 kind: "rendered",
420 provenance: Some("attributed"),
421 vouched: Some(true),
422 edited_after_added: None,
423 word: "retired",
424 replaceable: false,
425 },
426 ],
427 };
428 assert_eq!(
429 serde_json::to_string(&report).expect("a report serializes"),
430 r#"{"schema":"rk.stage-compare/3","stage_root":"/state/stages/abc/0.9.0","target":"/srv/widget","history":{"available":true,"reason":null},"rows":[{"path":".github/workflows/release-plz.yml","state":"differs","kind":"rendered","provenance":"reduced","vouched":false,"edited_after_added":null,"word":"collision","replaceable":true},{"path":"AGENTS.md","state":"equal","kind":"region","provenance":null,"vouched":true,"edited_after_added":null,"word":"replaced","replaceable":false},{"path":".github/workflows/old-release.yml","state":"retired","kind":"rendered","provenance":"attributed","vouched":true,"edited_after_added":null,"word":"retired","replaceable":false}]}"#
431 );
432 assert!(!super::rendered_equal(&report));
433 }
434
435 #[test]
439 fn only_a_pending_retirement_of_the_dropped_rows_counts_against_the_proof() {
440 let row = |path: &str, state: &'static str, word: &'static str| Row {
441 path: path.into(),
442 state,
443 kind: "rendered",
444 provenance: None,
445 vouched: None,
446 edited_after_added: None,
447 word,
448 replaceable: false,
449 };
450 let mut report = Report {
451 schema: super::SCHEMA,
452 stage_root: "/state/stages/abc/0.9.0".into(),
453 target: "/srv/widget".into(),
454 history: History {
455 available: true,
456 reason: None,
457 },
458 rows: vec![
459 row(".github/workflows/release-plz.yml", "equal", "matched"),
460 row(".github/workflows/legacy.yml", "dropped", "dropped"),
461 row(".github/workflows/old-release.yml", "equal", "retired"),
462 ],
463 };
464 assert!(super::rendered_equal(&report));
465 report
466 .rows
467 .push(row(".github/workflows/old.yml", "retired", "retired"));
468 assert!(!super::rendered_equal(&report));
469 }
470}