1use std::path::{Path, PathBuf};
18
19use serde_json::Value;
20
21use crate::census::{self, Accepted, Census, Change, Reports};
22use crate::real_flight::RealFlightReport;
23use crate::report::Report;
24use crate::run::ValidateError;
25
26pub const LATEST_JSON: &str = "validation/reports/latest.json";
28pub const LATEST_MD: &str = "validation/reports/latest.md";
30pub const CENSUS_JSON: &str = "validation/reports/census.json";
32
33pub const CENSUS_MISSING_HINT: &str = "run `cargo xtask census --accept --reason <why>`";
35
36pub const REPORT_HINT: &str =
38 "run `cargo xtask validate` and commit validation/reports/latest.{md,json}";
39pub const CENSUS_MD: &str = "validation/reports/census.md";
41pub const CENSUS_URL: &str =
43 "https://github.com/nrdptel/hpr-sim/blob/main/validation/reports/census.md";
44pub const BADGES: &str = "docs/images";
46pub const TABLES: [&str; 2] = ["README.md", "docs/accuracy.md"];
48pub const BEGIN: &str = "<!-- census: written by `cargo xtask census --accept` from \
50 validation/reports/census.json; do not edit -->";
51pub const END: &str = "<!-- census: end -->";
53
54#[derive(Debug, Clone, PartialEq)]
56pub struct CensusCheck {
57 pub rows: usize,
59 pub changes: Vec<Change>,
61 pub stale: Vec<String>,
63}
64
65impl CensusCheck {
66 pub fn holds(&self) -> bool {
68 self.changes.is_empty() && self.stale.is_empty()
69 }
70
71 pub fn lines(&self) -> Vec<String> {
74 if self.holds() {
75 return vec![format!(
76 "census: the committed reports hold to the accepted census ({} rows)",
77 self.rows
78 )];
79 }
80 let mut lines = change_lines(&self.changes);
81 lines.extend(self.stale.iter().map(|line| format!("census: {line}")));
82 lines
83 }
84
85 pub fn problem(&self) -> Option<String> {
87 let worse = self
88 .changes
89 .iter()
90 .filter(|change| change.is_worse())
91 .count();
92 let mut problems = Vec::new();
93 if !self.changes.is_empty() {
94 problems.push(format!(
95 "{} row(s) differ from the accepted census, {worse} of them for the worse: a \
96 regression beyond its slack fails here",
97 self.changes.len()
98 ));
99 }
100 if !self.stale.is_empty() {
101 problems.push(format!("{} output(s) are stale", self.stale.len()));
102 }
103 (!problems.is_empty()).then(|| problems.join("; "))
104 }
105
106 pub fn help(&self) -> Vec<String> {
109 let mut help = Vec::new();
110 if !self.changes.is_empty() {
111 help.push(format!(
112 "if the change is meant, accept it in writing with `cargo xtask census --accept \
113 --reason <why>`, and commit {CENSUS_JSON}"
114 ));
115 }
116 if !self.stale.is_empty() {
117 help.push("run `cargo xtask census --accept`".to_owned());
118 }
119 help
120 }
121}
122
123pub fn change_lines(changes: &[Change]) -> Vec<String> {
125 if changes.is_empty() {
126 return vec!["census: no row differs from the accepted census".to_owned()];
127 }
128 let mut lines: Vec<String> = changes
129 .iter()
130 .map(|change| format!("census: {}", change.describe()))
131 .collect();
132 lines.push(format!(
133 "census: {} row(s) differ from the accepted census, {} for the worse",
134 changes.len(),
135 changes.iter().filter(|change| change.is_worse()).count()
136 ));
137 lines
138}
139
140#[derive(Debug)]
142pub enum NotReproduced {
143 Unreadable(ValidateError),
145 Differs(String),
147}
148
149#[derive(Debug)]
151pub struct Checked {
152 pub failed: usize,
154 pub reproduced: Result<(), NotReproduced>,
156 pub census: Result<CensusCheck, ValidateError>,
158}
159
160impl Checked {
161 pub fn passed(&self) -> bool {
163 self.problems().is_empty()
164 }
165
166 pub fn problems(&self) -> Vec<String> {
169 let mut problems = Vec::new();
170 match &self.census {
171 Ok(census) => problems.extend(census.problem()),
172 Err(error) => problems.push(error.fact()),
173 }
174 if self.failed != 0 {
175 problems.push(format!("{} metric(s) outside tolerance", self.failed));
176 }
177 match &self.reproduced {
178 Ok(()) => {}
179 Err(NotReproduced::Unreadable(error)) => problems.push(error.fact()),
180 Err(NotReproduced::Differs(what)) => {
181 problems.push(format!("the committed report is not this run's ({what})"));
182 }
183 }
184 problems
185 }
186
187 pub fn help(&self) -> Vec<String> {
190 let mut help = match &self.census {
191 Ok(census) => census.help(),
192 Err(error) => error.help().map(str::to_owned).into_iter().collect(),
193 };
194 match &self.reproduced {
195 Ok(()) => {}
196 Err(NotReproduced::Unreadable(error)) => help.extend(error.help().map(str::to_owned)),
197 Err(NotReproduced::Differs(_)) => help.push(REPORT_HINT.to_owned()),
198 }
199 help
200 }
201
202 pub fn lines(&self) -> Vec<String> {
205 let mut lines = match &self.census {
206 Ok(census) => census.lines(),
207 Err(_) => Vec::new(),
208 };
209 if self.failed == 0 && self.reproduced.is_ok() {
210 lines.push("validate: the committed report reproduces".to_owned());
211 }
212 lines
213 }
214}
215
216pub fn check(root: &Path, report: &Report) -> Checked {
218 let reproduced = match (read(root, LATEST_MD), read(root, LATEST_JSON)) {
219 (Ok(markdown), Ok(json)) => report
220 .reproduces(&markdown, &json)
221 .map_err(|error| NotReproduced::Differs(error.to_string())),
222 (Err(error), _) | (_, Err(error)) => Err(NotReproduced::Unreadable(error)),
223 };
224 Checked {
225 failed: report.failures().len(),
226 reproduced,
227 census: check_census(root),
228 }
229}
230
231pub fn check_census(root: &Path) -> Result<CensusCheck, ValidateError> {
238 let changes = census_changes(root)?;
239 let accepted = read_accepted(root)?.ok_or_else(|| {
240 ValidateError::Case(format!("{CENSUS_JSON} is missing; {CENSUS_MISSING_HINT}"))
241 })?;
242 let stale = stale(census_outputs(root, &accepted)?, |path| {
243 std::fs::read_to_string(root.join(path))
244 });
245 Ok(CensusCheck {
246 rows: accepted.census.rows.len(),
247 changes,
248 stale,
249 })
250}
251
252pub fn stale(
254 outputs: Vec<(PathBuf, String)>,
255 read: impl Fn(&Path) -> std::io::Result<String>,
256) -> Vec<String> {
257 outputs
258 .into_iter()
259 .filter_map(|(path, text)| match read(&path) {
260 Ok(committed) if committed == text => None,
261 Ok(_) => Some(format!(
262 "{} is not what the accepted census writes",
263 path.display()
264 )),
265 Err(error) => Some(format!("{}: {error}", path.display())),
266 })
267 .collect()
268}
269
270pub fn census_changes(root: &Path) -> Result<Vec<Change>, ValidateError> {
277 let now = take_census(root)?;
278 let accepted = read_accepted(root)?;
279 let empty = Census {
280 slack_share: census::SLACK_SHARE,
281 references: now.references.clone(),
282 rows: Vec::new(),
283 };
284 Ok(census::compare(
285 accepted
286 .as_ref()
287 .map_or(&empty, |accepted| &accepted.census),
288 &now,
289 ))
290}
291
292pub fn census_outputs(
301 root: &Path,
302 accepted: &Accepted,
303) -> Result<Vec<(PathBuf, String)>, ValidateError> {
304 let accepted = accepted.refreshed();
305 let json = serde_json::to_string_pretty(&accepted).map_err(|source| ValidateError::Json {
306 path: CENSUS_JSON.to_owned(),
307 source: Box::new(source),
308 })?;
309 let mut files = vec![
310 (PathBuf::from(CENSUS_JSON), format!("{json}\n")),
311 (PathBuf::from(CENSUS_MD), accepted.to_markdown()),
312 ];
313 for (name, svg) in census::badges(&accepted.summaries) {
314 files.push((Path::new(BADGES).join(name), svg));
315 }
316 let block = format!(
317 "{BEGIN}\n\n{}\n{END}",
318 census::table(&accepted.summaries, Some(CENSUS_URL))
319 );
320 for path in TABLES {
321 let text = read(root, path)?;
322 files.push((
323 PathBuf::from(path),
324 splice(&text, &block, path).map_err(ValidateError::Case)?,
325 ));
326 }
327 Ok(files)
328}
329
330pub fn splice(text: &str, block: &str, path: &str) -> Result<String, String> {
336 let start = text
337 .find(BEGIN)
338 .ok_or_else(|| format!("{path} has no census block: add the line `{BEGIN}` and `{END}`"))?;
339 let end = text[start..]
340 .find(END)
341 .map(|at| start + at + END.len())
342 .ok_or_else(|| format!("{path}: the census block has no `{END}`"))?;
343 if text[end..].contains(BEGIN) {
344 return Err(format!("{path} has two census blocks"));
345 }
346 Ok(format!("{}{block}{}", &text[..start], &text[end..]))
347}
348
349pub fn read_accepted(root: &Path) -> Result<Option<Accepted>, ValidateError> {
355 if root.join(CENSUS_JSON).exists() {
356 read_json(root, CENSUS_JSON).map(Some)
357 } else {
358 Ok(None)
359 }
360}
361
362pub fn take_census(root: &Path) -> Result<Census, ValidateError> {
368 let harness: Report = read_json(root, LATEST_JSON)?;
369 let real: RealFlightReport = read_json(root, "validation/reports/real-flights.json")?;
370 let examples: Value = read_json(root, "validation/reports/openrocket-flights.json")?;
371 let library: Value = read_json(root, "validation/reports/openrocket-library-flights.json")?;
372 Census::take(Reports {
373 harness: &harness,
374 real_flights: &real,
375 openrocket_examples: &examples,
376 openrocket_library: &library,
377 })
378 .map_err(|error| ValidateError::Case(error.to_string()))
379}
380
381fn read(root: &Path, relative: &str) -> Result<String, ValidateError> {
382 let path = root.join(relative);
383 std::fs::read_to_string(&path).map_err(|source| ValidateError::Io {
384 what: "reading",
385 path: path.display().to_string(),
386 source,
387 })
388}
389
390fn read_json<T: serde::de::DeserializeOwned>(
391 root: &Path,
392 relative: &str,
393) -> Result<T, ValidateError> {
394 serde_json::from_str(&read(root, relative)?).map_err(|source| ValidateError::Json {
395 path: root.join(relative).display().to_string(),
396 source: Box::new(source),
397 })
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 fn copy_tree(from: &Path, to: &Path) {
406 std::fs::create_dir_all(to).unwrap();
407 for entry in std::fs::read_dir(from).unwrap() {
408 let entry = entry.unwrap();
409 let target = to.join(entry.file_name());
410 if entry.file_type().unwrap().is_dir() {
411 copy_tree(&entry.path(), &target);
412 } else {
413 std::fs::copy(entry.path(), &target).unwrap();
414 }
415 }
416 }
417
418 fn spoil(path: &Path, from: &str, to: &str) -> String {
420 let text = std::fs::read_to_string(path).unwrap();
421 assert!(text.contains(from), "{} has no `{from}`", path.display());
422 std::fs::write(path, text.replacen(from, to, 1)).unwrap();
423 text
424 }
425
426 #[test]
431 fn a_copy_passes_and_fails_where_it_is_spoiled() {
432 let root = crate::tests::root();
433 let dir = tempfile::tempdir().unwrap();
434 let at = dir.path();
435 copy_tree(&root.join("validation"), &at.join("validation"));
436 copy_tree(&root.join("docs/images"), &at.join("docs/images"));
437 for file in ["README.md", "docs/accuracy.md"] {
438 std::fs::copy(root.join(file), at.join(file)).unwrap();
439 }
440 let report = crate::run_lock(at, false).unwrap();
441 let checked = check(at, &report);
442 assert!(
443 checked.passed(),
444 "the copy is whole: {:?}",
445 checked.problems()
446 );
447 assert_eq!(
448 checked.lines().last().map(String::as_str),
449 Some("validate: the committed report reproduces")
450 );
451
452 let latest = at.join(LATEST_JSON);
454 let committed = std::fs::read_to_string(&latest).unwrap();
455 let mut json: serde_json::Value = serde_json::from_str(&committed).unwrap();
456 let measured = &mut json["comparisons"][0]["measured"];
457 *measured = serde_json::Value::from(measured.as_f64().unwrap() * 1.01);
458 std::fs::write(&latest, serde_json::to_string_pretty(&json).unwrap()).unwrap();
459 let checked = check(at, &report);
460 assert!(checked.reproduced.is_err());
461 let problems = checked.problems();
462 assert_eq!(problems.len(), 1, "{problems:?}");
463 assert!(
464 problems[0].starts_with("the committed report is not this run's"),
465 "{problems:?}"
466 );
467 assert_eq!(checked.help(), [REPORT_HINT]);
468 std::fs::write(&latest, committed).unwrap();
469
470 let readme = at.join("README.md");
472 let was = spoil(
473 &readme,
474 "<!-- census: end -->",
475 "edited\n<!-- census: end -->",
476 );
477 let checked = check(at, &report);
478 assert!(checked.reproduced.is_ok());
479 assert_eq!(
480 checked.census.as_ref().unwrap().stale,
481 ["README.md is not what the accepted census writes"]
482 );
483 assert_eq!(checked.problems(), ["1 output(s) are stale"]);
484 assert_eq!(checked.help(), ["run `cargo xtask census --accept`"]);
485 std::fs::write(&readme, was).unwrap();
486 assert!(check(at, &report).passed(), "restored");
487
488 let case = at.join("validation/cases/descent-valetudo.toml");
490 spoil(&case, "relative = 0.03", "relative = 1e-12");
491 let report = crate::run_lock(at, false).unwrap();
492 let checked = check(at, &report);
493 assert_eq!(checked.failed, 1);
494 assert_eq!(checked.failed, report.failures().len());
495 let problems = checked.problems();
496 assert!(
497 problems.contains(&"1 metric(s) outside tolerance".to_owned()),
498 "{problems:?}"
499 );
500 assert!(!checked.passed());
501 assert!(
502 !checked
503 .lines()
504 .contains(&"validate: the committed report reproduces".to_owned())
505 );
506 }
507
508 fn census(stale: usize) -> CensusCheck {
510 CensusCheck {
511 rows: 7,
512 changes: Vec::new(),
513 stale: vec!["README.md is not what the accepted census writes".to_owned(); stale],
514 }
515 }
516
517 #[test]
519 fn a_check_says_each_reason_once_in_order() {
520 let passed = Checked {
521 failed: 0,
522 reproduced: Ok(()),
523 census: Ok(census(0)),
524 };
525 assert!(passed.passed());
526 assert_eq!(
527 passed.lines(),
528 [
529 "census: the committed reports hold to the accepted census (7 rows)",
530 "validate: the committed report reproduces"
531 ]
532 );
533 let failed = Checked {
534 failed: 2,
535 reproduced: Err(NotReproduced::Differs("latest.json: x".to_owned())),
536 census: Ok(census(1)),
537 };
538 assert_eq!(
539 failed.problems(),
540 [
541 "1 output(s) are stale",
542 "2 metric(s) outside tolerance",
543 "the committed report is not this run's (latest.json: x)"
544 ]
545 );
546 assert_eq!(
547 failed.help(),
548 [
549 "run `cargo xtask census --accept`",
550 "run `cargo xtask validate` and commit validation/reports/latest.{md,json}"
551 ]
552 );
553 assert_eq!(
554 failed.lines(),
555 [
556 "census: no row differs from the accepted census",
557 "census: README.md is not what the accepted census writes"
558 ]
559 );
560 let unread = Checked {
563 failed: 0,
564 reproduced: Err(NotReproduced::Unreadable(ValidateError::Case(
565 "reading x".into(),
566 ))),
567 census: Err(ValidateError::Case("no census".into())),
568 };
569 assert_eq!(unread.problems(), ["no census", "reading x"]);
570 assert!(unread.help().is_empty());
571 let missing = Checked {
573 failed: 0,
574 reproduced: Ok(()),
575 census: Err(ValidateError::Case(format!(
576 "{CENSUS_JSON} is missing; {CENSUS_MISSING_HINT}"
577 ))),
578 };
579 assert_eq!(
580 missing.problems(),
581 ["validation/reports/census.json is missing"]
582 );
583 assert_eq!(missing.help(), [CENSUS_MISSING_HINT]);
584 assert!(unread.lines().is_empty());
585 }
586
587 #[test]
588 fn a_block_is_replaced_whole_and_only_once() {
589 let text = format!("before\n{BEGIN}\nold\n{END}\nafter\n");
590 let block = format!("{BEGIN}\nnew\n{END}");
591 assert_eq!(
592 splice(&text, &block, "f").unwrap(),
593 format!("before\n{BEGIN}\nnew\n{END}\nafter\n")
594 );
595 assert!(
596 splice("no block", &block, "f")
597 .unwrap_err()
598 .contains("no census block")
599 );
600 assert!(
601 splice(&format!("{BEGIN}\nold"), &block, "f")
602 .unwrap_err()
603 .contains("no `")
604 );
605 let twice = format!("{text}{text}");
606 assert!(splice(&twice, &block, "f").unwrap_err().contains("two"));
607 }
608}