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