1use std::collections::BTreeMap;
54use std::path::{Path, PathBuf};
55
56use schemars::JsonSchema;
57use serde::{Deserialize, Serialize};
58
59use super::freshness::TreeState;
60use super::run::EvidenceProducer;
61use super::{digest_of, RunRef, LEDGER_PATH};
62use crate::error::{Error, Result};
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
72#[serde(rename_all = "snake_case")]
73pub enum EvidenceToolchainSource {
74 Measured,
76 Pinned,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
89#[serde(deny_unknown_fields)]
90pub struct EvidenceToolchain {
91 pub name: String,
93 pub version: String,
95 pub source: EvidenceToolchainSource,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
108#[serde(deny_unknown_fields)]
109pub struct EvidenceReportArtifact {
110 pub path: String,
113 pub digest: String,
115 pub bytes: u64,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
127#[serde(deny_unknown_fields)]
128pub struct EvidenceHost {
129 pub os: String,
131 pub arch: String,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
151#[serde(deny_unknown_fields)]
152pub struct EvidenceProvenance {
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub producer: Option<EvidenceProducer>,
156 pub commit: String,
158 pub working_tree: TreeState,
160 #[serde(default)]
163 pub excluded: Vec<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub measured_at: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub toolchain: Option<EvidenceToolchain>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub recorder: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub report: Option<EvidenceReportArtifact>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub host: Option<EvidenceHost>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub run: Option<RunRef>,
182}
183
184#[derive(Debug, Clone, Default)]
192pub struct StampRequest {
193 pub producer: Option<EvidenceProducer>,
195 pub report: Option<PathBuf>,
197 pub exclude: Vec<String>,
199 pub run: Option<RunRef>,
201}
202
203fn refused(reason: impl Into<String>) -> Error {
204 Error::InvalidSurface {
205 surface: "evidence".into(),
206 reason: reason.into(),
207 }
208}
209
210pub fn stamp(root: &Path, req: &StampRequest) -> Result<EvidenceProvenance> {
223 let commit = match crate::git::inspect(root) {
224 crate::git::GitState::Available(i) => i.head,
225 crate::git::GitState::Unavailable { .. } => None,
226 }
227 .ok_or_else(|| {
228 refused("there is no commit to measure: a run measured here would carry no provenance")
229 })?;
230
231 let mut outputs = Vec::new();
232 for e in &req.exclude {
233 outputs.push(checked_exclude(root, e)?);
234 }
235
236 let report = match &req.report {
237 Some(path) => {
238 let bytes = std::fs::read(path).map_err(|e| {
239 refused(format!("the report {} cannot be read: {e}", path.display()))
240 })?;
241 let (name, inside) = report_path(root, path);
242 if inside {
243 outputs.push(name.clone());
244 }
245 Some(EvidenceReportArtifact {
246 path: name,
247 digest: digest_of(&bytes),
248 bytes: bytes.len() as u64,
249 })
250 }
251 None => None,
252 };
253 crate::order::canonical(&mut outputs);
255 outputs.dedup();
256
257 let refs: Vec<&str> = outputs.iter().map(String::as_str).collect();
258 let working_tree = TreeState::parse(&crate::git::working_tree_excluding(
259 root,
260 &[LEDGER_PATH],
261 &refs,
262 ));
263
264 Ok(EvidenceProvenance {
265 producer: req.producer,
266 commit,
267 working_tree,
268 excluded: outputs,
269 measured_at: Some(super::record::now_rfc3339()),
270 toolchain: req.producer.and_then(|p| toolchain(root, p)),
271 recorder: Some(crate::VERSION.to_string()),
272 report,
273 host: Some(EvidenceHost {
274 os: std::env::consts::OS.into(),
275 arch: std::env::consts::ARCH.into(),
276 }),
277 run: req.run.clone(),
278 })
279}
280
281fn checked_exclude(root: &Path, raw: &str) -> Result<String> {
283 let mut e = raw;
284 while let Some(rest) = e.strip_prefix("./") {
285 e = rest;
286 }
287 let e = e.trim_end_matches('/');
288 if e.is_empty() || e == "." {
289 return Err(refused(format!("`{raw}` names the whole checkout")));
290 }
291 let not_a_path = Path::new(e).is_absolute()
292 || e.split('/').any(|s| s == "..")
293 || e.starts_with(':')
294 || e.starts_with('-')
295 || e.contains('\0');
296 if not_a_path {
297 return Err(refused(format!("`{raw}` is not a repository path")));
298 }
299 if e.contains(['*', '?', '[', '\\']) {
300 return Err(refused(format!(
301 "`{raw}`: an exclude is a path, not a pattern"
302 )));
303 }
304 match crate::git::ls_files(root, &format!(":(literal){e}")) {
305 Ok(tracked) if tracked.is_empty() => Ok(e.to_string()),
306 Ok(_) => Err(refused(format!(
307 "`{raw}` holds tracked files: an exclude names only the run's own untracked \
308 outputs, and a change to a tracked file is always a change"
309 ))),
310 Err(err) => Err(refused(format!(
311 "`{raw}` could not be checked against the index: {err}"
312 ))),
313 }
314}
315
316fn report_path(root: &Path, report: &Path) -> (String, bool) {
319 let canonical = |p: &Path| std::fs::canonicalize(p).ok();
320 if let (Some(r), Some(root)) = (canonical(report), canonical(root)) {
321 if let Ok(rel) = r.strip_prefix(&root) {
322 let parts: Vec<String> = rel
323 .components()
324 .map(|c| c.as_os_str().to_string_lossy().into_owned())
325 .collect();
326 return (parts.join("/"), true);
327 }
328 }
329 let name = report.file_name().map(|n| n.to_string_lossy().into_owned());
330 (name.unwrap_or_default(), false)
331}
332
333fn toolchain(root: &Path, producer: EvidenceProducer) -> Option<EvidenceToolchain> {
336 use crate::environment::toolchain as tc;
337 let measured = |name: &str, version: String| EvidenceToolchain {
338 name: name.into(),
339 version,
340 source: EvidenceToolchainSource::Measured,
341 };
342 match producer {
343 EvidenceProducer::Crate | EvidenceProducer::Coverage => {
344 let pinned = tc::declared(root).into_iter().find(|t| {
345 t.id == "rust"
346 && matches!(
347 t.declared_by.as_str(),
348 "rust-toolchain.toml" | "rust-toolchain"
349 )
350 });
351 if let Some(version) = pinned.and_then(|t| t.declared) {
352 return Some(EvidenceToolchain {
353 name: "rustc".into(),
354 version,
355 source: EvidenceToolchainSource::Pinned,
356 });
357 }
358 version_of("rustc")
359 .as_deref()
360 .and_then(rustc_version)
361 .map(|v| measured("rustc", v))
362 }
363 EvidenceProducer::Suite => version_of("bash")
364 .as_deref()
365 .and_then(bash_version)
366 .map(|v| measured("bash", v)),
367 }
368}
369
370fn version_of(exe: &str) -> Option<String> {
373 use crate::environment::{probe::bounded_output, toolchain as tc};
374 let out = bounded_output(
375 std::process::Command::new(exe).arg("--version"),
376 tc::VERSION_TIMEOUT,
377 )?;
378 out.status
379 .success()
380 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
381}
382
383fn rustc_version(text: &str) -> Option<String> {
385 text.split_whitespace().nth(1).map(str::to_string)
386}
387
388fn bash_version(text: &str) -> Option<String> {
390 let first = text.lines().next()?;
391 let (_, rest) = first.split_once("version ")?;
392 rest.split_whitespace().next().map(str::to_string)
393}
394
395impl EvidenceProvenance {
396 pub fn read(path: &Path) -> std::result::Result<EvidenceProvenance, String> {
407 let text = std::fs::read_to_string(path)
408 .map_err(|e| format!("the measurement {} cannot be read: {e}", path.display()))?;
409 let value: serde_json::Value = serde_json::from_str(&text)
410 .map_err(|e| format!("the measurement {} is not JSON: {e}", path.display()))?;
411 let tree = value.get("working_tree").and_then(|t| t.as_str());
414 if let Some(word) = tree {
415 if !matches!(word, "clean" | "dirty" | "unknown") {
416 return Err(format!(
417 "the measurement {} states the tree `{word}`, which is not clean, dirty \
418 or unknown",
419 path.display()
420 ));
421 }
422 }
423 serde_json::from_value(value)
424 .map_err(|e| format!("the measurement {} is not one: {e}", path.display()))
425 }
426
427 pub fn resolve(
446 values: &[String],
447 given: &[EvidenceProducer],
448 ) -> std::result::Result<BTreeMap<EvidenceProducer, EvidenceProvenance>, String> {
449 let mut out = BTreeMap::new();
450 for value in values {
451 let (key, file) = match value.split_once('=') {
452 Some((k, f)) => match EvidenceProducer::parse(k) {
453 Some(p) => (Some(p), f),
454 None => (None, value.as_str()),
455 },
456 None => (None, value.as_str()),
457 };
458 if file.is_empty() {
459 return Err(format!("`{value}` names no measurement file"));
460 }
461 let mut prov = EvidenceProvenance::read(Path::new(file))?;
462 let producer = match (key, prov.producer, given) {
463 (Some(k), _, _) => k,
464 (None, Some(own), _) => own,
465 (None, None, [only]) => *only,
466 (None, None, _) => {
467 return Err(format!(
468 "`{file}` names no producer and more than one report was given: give \
469 it as `<producer>=<file>`"
470 ))
471 }
472 };
473 if let Some(own) = prov.producer.filter(|own| *own != producer) {
474 return Err(format!(
475 "`{file}` measures the {} report, not the {} one",
476 own.as_str(),
477 producer.as_str()
478 ));
479 }
480 prov.producer = Some(producer);
481 if out.insert(producer, prov).is_some() {
482 return Err(format!(
483 "the {} report was given two measurements",
484 producer.as_str()
485 ));
486 }
487 }
488 Ok(out)
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use std::process::Command;
496
497 fn git(dir: &Path, args: &[&str]) -> String {
498 let out = Command::new("git")
499 .arg("-C")
500 .arg(dir)
501 .args(args)
502 .output()
503 .unwrap();
504 assert!(out.status.success(), "git {args:?}");
505 String::from_utf8(out.stdout).unwrap().trim().to_string()
506 }
507
508 fn repo(files: &[&str]) -> tempfile::TempDir {
509 let dir = tempfile::tempdir().unwrap();
510 git(dir.path(), &["init", "-q"]);
511 git(dir.path(), &["config", "user.email", "t@example.com"]);
512 git(dir.path(), &["config", "user.name", "t"]);
513 for f in files {
514 write(dir.path(), f, "committed");
515 }
516 git(dir.path(), &["add", "-A"]);
517 git(dir.path(), &["commit", "-qm", "init"]);
518 dir
519 }
520
521 fn write(root: &Path, path: &str, text: &str) {
522 let p = root.join(path);
523 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
524 std::fs::write(p, text).unwrap();
525 }
526
527 fn excluding(exclude: &[&str]) -> StampRequest {
528 StampRequest {
529 exclude: exclude.iter().map(|e| e.to_string()).collect(),
530 ..StampRequest::default()
531 }
532 }
533
534 fn refusal(root: &Path, req: &StampRequest) -> String {
535 stamp(root, req).unwrap_err().to_string()
536 }
537
538 #[test]
539 fn a_stamp_on_a_committed_tree_is_clean_and_names_its_commit() {
540 let dir = repo(&["a.md"]);
541 let p = stamp(dir.path(), &StampRequest::default()).unwrap();
542 assert_eq!(p.commit, git(dir.path(), &["rev-parse", "HEAD"]));
543 assert_eq!(p.working_tree, TreeState::Clean);
544 assert!(p.excluded.is_empty() && p.report.is_none() && p.producer.is_none());
545 assert_eq!(p.recorder.as_deref(), Some(crate::VERSION));
546 assert_eq!(p.host.as_ref().unwrap().os, std::env::consts::OS);
547 assert!(p.measured_at.as_ref().unwrap().ends_with('Z'));
548 }
549
550 #[test]
551 fn the_ledgers_own_working_copy_does_not_make_a_stamp_dirty() {
552 let dir = repo(&["a.md", LEDGER_PATH]);
553 write(dir.path(), LEDGER_PATH, "{\"changed\": true}");
554 assert_eq!(
555 stamp(dir.path(), &StampRequest::default())
556 .unwrap()
557 .working_tree,
558 TreeState::Clean
559 );
560 write(dir.path(), "a.md", "edited");
561 assert_eq!(
562 stamp(dir.path(), &StampRequest::default())
563 .unwrap()
564 .working_tree,
565 TreeState::Dirty
566 );
567 }
568
569 #[test]
570 fn an_excluded_directory_is_excluded_with_everything_under_it_and_nothing_else_is() {
571 let dir = repo(&["a.md"]);
572 write(dir.path(), "dist/out.bin", "x");
573 write(dir.path(), "dist/deep/more.bin", "x");
574 write(dir.path(), "timings.tsv", "x");
575 let got = stamp(dir.path(), &excluding(&["./dist/", "timings.tsv"])).unwrap();
576 assert_eq!(got.working_tree, TreeState::Clean);
577 assert_eq!(got.excluded, ["dist", "timings.tsv"], "normalised, sorted");
578 let got = stamp(dir.path(), &excluding(&["timings.tsv"])).unwrap();
579 assert_eq!(got.working_tree, TreeState::Dirty, "dist is not excluded");
580 write(dir.path(), "distx/y", "x");
581 let got = stamp(dir.path(), &excluding(&["dist", "timings.tsv"])).unwrap();
582 assert_eq!(
583 got.working_tree,
584 TreeState::Dirty,
585 "distx is not under dist"
586 );
587 }
588
589 #[test]
590 fn an_exclude_that_holds_tracked_files_is_refused() {
591 let dir = repo(&["a.md", "test/cases/01_a.sh"]);
592 write(dir.path(), "test/cases/01_a.sh", "edited");
593 for e in [".", "./", "test", "test/", "test/cases/01_a.sh"] {
594 let err = refusal(dir.path(), &excluding(&[e]));
595 assert!(err.contains(&format!("`{e}`")), "{e}: {err}");
596 assert!(
597 err.contains("whole checkout") || err.contains("tracked files"),
598 "{e}: {err}"
599 );
600 }
601 }
602
603 #[test]
604 fn an_exclude_that_is_a_pattern_or_not_a_repository_path_is_refused() {
605 let dir = repo(&["a.md"]);
606 for e in ["*", "a?", "[ab]", "a\\b"] {
607 let err = refusal(dir.path(), &excluding(&[e]));
608 assert!(err.contains("a path, not a pattern"), "{e}: {err}");
609 }
610 for e in ["/tmp", "../x", "a/../b", ":x", "-x", "a\0b"] {
611 let err = refusal(dir.path(), &excluding(&[e]));
612 assert!(err.contains("is not a repository path"), "{e}: {err}");
613 }
614 let err = refusal(dir.path(), &excluding(&[""]));
615 assert!(err.contains("whole checkout"), "{err}");
616 }
617
618 #[test]
619 fn an_exclude_that_cannot_be_checked_against_the_index_is_refused() {
620 let dir = repo(&["a.md"]);
622 std::fs::write(dir.path().join(".git/index"), "not an index").unwrap();
623 let err = refusal(dir.path(), &excluding(&["dist"]));
624 assert!(
625 err.contains("could not be checked against the index"),
626 "{err}"
627 );
628 }
629
630 #[test]
631 fn a_report_inside_the_checkout_is_one_of_the_runs_outputs() {
632 let dir = repo(&["a.md"]);
633 write(dir.path(), "out/suite.tsv", "01_a\tok\t1\tparallel\n");
634 let req = StampRequest {
635 report: Some(dir.path().join("out/suite.tsv")),
636 ..StampRequest::default()
637 };
638 let got = stamp(dir.path(), &req).unwrap();
639 assert_eq!(got.working_tree, TreeState::Clean);
640 assert_eq!(got.excluded, ["out/suite.tsv"]);
641 assert_eq!(got.report.unwrap().path, "out/suite.tsv");
642 }
643
644 #[test]
645 fn a_stamp_without_a_commit_is_refused() {
646 let dir = tempfile::tempdir().unwrap();
647 assert!(refusal(dir.path(), &StampRequest::default()).contains("no commit to measure"));
648 git(dir.path(), &["init", "-q"]);
649 assert!(refusal(dir.path(), &StampRequest::default()).contains("no commit to measure"));
650 }
651
652 #[test]
653 fn the_report_is_named_without_a_machine_path_and_digested() {
654 let dir = repo(&["a.md"]);
655 let elsewhere = tempfile::tempdir().unwrap();
656 let report = elsewhere.path().join("all.tsv");
657 std::fs::write(&report, "01_a\tok\t1\tparallel\n").unwrap();
658 let req = StampRequest {
659 report: Some(report.clone()),
660 ..StampRequest::default()
661 };
662 let got = stamp(dir.path(), &req).unwrap();
663 let artifact = got.report.unwrap();
664 assert_eq!(artifact.path, "all.tsv");
665 assert_eq!(artifact.digest, digest_of(&std::fs::read(&report).unwrap()));
666 assert_eq!(artifact.bytes, 19);
667 assert!(
668 got.excluded.is_empty(),
669 "a report outside the checkout is no output of it"
670 );
671
672 let missing = StampRequest {
673 report: Some(elsewhere.path().join("absent.tsv")),
674 ..StampRequest::default()
675 };
676 assert!(refusal(dir.path(), &missing).contains("absent.tsv"));
677 }
678
679 #[test]
680 fn the_crate_toolchain_is_pinned_from_rust_toolchain_toml() {
681 let dir = repo(&["a.md"]);
682 write(
683 dir.path(),
684 "rust-toolchain.toml",
685 "[toolchain]\nchannel = \"1.2.3\"\n",
686 );
687 git(dir.path(), &["add", "-A"]);
688 git(dir.path(), &["commit", "-qm", "pin"]);
689 for p in [EvidenceProducer::Crate, EvidenceProducer::Coverage] {
690 let req = StampRequest {
691 producer: Some(p),
692 ..StampRequest::default()
693 };
694 let t = stamp(dir.path(), &req).unwrap().toolchain.unwrap();
695 assert_eq!(
696 t,
697 EvidenceToolchain {
698 name: "rustc".into(),
699 version: "1.2.3".into(),
700 source: EvidenceToolchainSource::Pinned,
701 }
702 );
703 }
704 }
705
706 #[test]
707 fn an_unpinned_toolchain_is_measured() {
708 let dir = repo(&["a.md"]);
709 write(
711 dir.path(),
712 "Cargo.toml",
713 "[package]\nrust-version = \"1.0\"\n",
714 );
715 if let Some(t) = toolchain(dir.path(), EvidenceProducer::Crate) {
716 assert_eq!(
717 (t.name.as_str(), t.source),
718 ("rustc", EvidenceToolchainSource::Measured)
719 );
720 assert_ne!(t.version, "1.0");
721 }
722 let bash = toolchain(dir.path(), EvidenceProducer::Suite).unwrap();
723 assert_eq!(
724 (bash.name.as_str(), bash.source),
725 ("bash", EvidenceToolchainSource::Measured)
726 );
727 assert!(
728 bash.version.chars().next().unwrap().is_ascii_digit(),
729 "{}",
730 bash.version
731 );
732 }
733
734 #[test]
735 fn a_stamp_without_a_producer_measures_no_toolchain() {
736 let dir = repo(&["a.md"]);
737 write(
738 dir.path(),
739 "rust-toolchain.toml",
740 "[toolchain]\nchannel = \"1.2.3\"\n",
741 );
742 assert!(stamp(dir.path(), &StampRequest::default())
743 .unwrap()
744 .toolchain
745 .is_none());
746 }
747
748 #[test]
749 fn a_minimal_measurement_reads_as_a_provenance() {
750 let dir = tempfile::tempdir().unwrap();
751 let f = dir.path().join("suite-tree.json");
752 let head = "0123456789abcdef0123456789abcdef01234567";
753 std::fs::write(
754 &f,
755 format!(r#"{{"commit":"{head}","working_tree":"clean","excluded":["suite.tsv"]}}"#),
756 )
757 .unwrap();
758 let p = EvidenceProvenance::read(&f).unwrap();
759 assert_eq!(
760 (p.commit.as_str(), p.working_tree),
761 (head, TreeState::Clean)
762 );
763 assert_eq!(p.excluded, ["suite.tsv"]);
764 assert!(p.producer.is_none() && p.report.is_none() && p.toolchain.is_none());
765 }
766
767 #[test]
768 fn a_measurement_with_an_unknown_field_is_refused() {
769 let dir = tempfile::tempdir().unwrap();
770 let f = dir.path().join("p.json");
771 for (text, why) in [
772 (
773 r#"{"commit":"c","working_tree":"clean","extra":1}"#,
774 "is not one",
775 ),
776 (r#"{"commit":"c","working_tree":"spotless"}"#, "`spotless`"),
777 (r#"{"working_tree":"clean"}"#, "is not one"),
778 ("not json", "is not JSON"),
779 ] {
780 std::fs::write(&f, text).unwrap();
781 let err = EvidenceProvenance::read(&f).unwrap_err();
782 assert!(err.contains("p.json") && err.contains(why), "{text}: {err}");
783 }
784 let err = EvidenceProvenance::read(&dir.path().join("absent.json")).unwrap_err();
785 assert!(err.contains("cannot be read"), "{err}");
786 }
787
788 fn measurement(dir: &Path, name: &str, producer: Option<&str>) -> String {
789 let f = dir.join(name);
790 let mut v = serde_json::json!({"commit": "c", "working_tree": "clean"});
791 if let Some(p) = producer {
792 v["producer"] = p.into();
793 }
794 std::fs::write(&f, v.to_string()).unwrap();
795 f.display().to_string()
796 }
797
798 #[test]
799 fn a_keyless_measurement_takes_its_producer_from_the_file_or_the_only_report() {
800 use EvidenceProducer::*;
801 let dir = tempfile::tempdir().unwrap();
802 let own = measurement(dir.path(), "own.json", Some("crate"));
803 let bare = measurement(dir.path(), "bare.json", None);
804 let got = EvidenceProvenance::resolve(std::slice::from_ref(&own), &[Suite, Crate]).unwrap();
805 assert_eq!(got.keys().copied().collect::<Vec<_>>(), [Crate]);
806 let got = EvidenceProvenance::resolve(std::slice::from_ref(&bare), &[Coverage]).unwrap();
807 assert_eq!(got[&Coverage].producer, Some(Coverage));
808 let keyed = format!("suite={bare}");
809 let got = EvidenceProvenance::resolve(&[keyed, own], &[Suite, Crate]).unwrap();
810 assert_eq!(got.keys().copied().collect::<Vec<_>>(), [Suite, Crate]);
811 assert!(got.values().all(|p| p.producer.is_some()));
812 }
813
814 #[test]
815 fn a_keyless_measurement_without_a_producer_among_several_reports_is_refused() {
816 use EvidenceProducer::*;
817 let dir = tempfile::tempdir().unwrap();
818 let bare = measurement(dir.path(), "bare.json", None);
819 for given in [&[Suite, Crate][..], &[][..]] {
820 let err = EvidenceProvenance::resolve(std::slice::from_ref(&bare), given).unwrap_err();
821 assert!(err.contains("give it as `<producer>=<file>`"), "{err}");
822 }
823 let err = EvidenceProvenance::resolve(&["suite=".to_string()], &[Suite]).unwrap_err();
824 assert!(err.contains("names no measurement file"), "{err}");
825 }
826
827 #[test]
828 fn a_key_that_disagrees_with_the_file_is_refused() {
829 let dir = tempfile::tempdir().unwrap();
830 let own = measurement(dir.path(), "own.json", Some("crate"));
831 let err =
832 EvidenceProvenance::resolve(&[format!("suite={own}")], &[EvidenceProducer::Suite])
833 .unwrap_err();
834 assert!(
835 err.contains("measures the crate report, not the suite one"),
836 "{err}"
837 );
838 }
839
840 #[test]
841 fn a_producer_given_twice_is_refused() {
842 let dir = tempfile::tempdir().unwrap();
843 let a = measurement(dir.path(), "a.json", Some("suite"));
844 let b = measurement(dir.path(), "b.json", None);
845 let err =
846 EvidenceProvenance::resolve(&[a, format!("suite={b}")], &[EvidenceProducer::Suite])
847 .unwrap_err();
848 assert!(
849 err.contains("the suite report was given two measurements"),
850 "{err}"
851 );
852 }
853
854 #[test]
856 fn a_measurement_without_a_tree_is_refused() {
857 let dir = tempfile::tempdir().unwrap();
858 let f = dir.path().join("p.json");
859 std::fs::write(&f, r#"{"commit":"c"}"#).unwrap();
860 let err = EvidenceProvenance::read(&f).unwrap_err();
861 assert!(err.contains("is not one"), "{err}");
862 }
863
864 #[test]
866 fn a_value_whose_key_is_no_producer_is_read_as_a_file_name() {
867 let dir = tempfile::tempdir().unwrap();
868 let value = format!("{}/nightly=p.json", dir.path().display());
869 let err = EvidenceProvenance::resolve(std::slice::from_ref(&value), &[]).unwrap_err();
870 assert!(
871 err.contains("cannot be read") && err.contains("nightly=p.json"),
872 "{err}"
873 );
874 }
875
876 #[test]
879 fn a_report_outside_the_checkout_is_named_by_its_file_name() {
880 let dir = repo(&["a.md"]);
881 let elsewhere = tempfile::tempdir().unwrap();
882 let outside = elsewhere.path().join("suite.tsv");
883 std::fs::write(&outside, "x").unwrap();
884 assert_eq!(
885 report_path(dir.path(), &outside),
886 ("suite.tsv".to_string(), false)
887 );
888 assert_eq!(
889 report_path(dir.path(), &elsewhere.path().join("absent.tsv")),
890 ("absent.tsv".to_string(), false)
891 );
892 }
893
894 #[test]
896 fn a_toolchain_that_states_no_version_measures_nothing() {
897 assert_eq!(
898 version_of("majordomus-no-such-executable-on-any-path"),
899 None
900 );
901 assert_eq!(
902 rustc_version("rustc 1.89.0 (abc 2025-01-01)"),
903 Some("1.89.0".into())
904 );
905 assert_eq!(rustc_version("rustc"), None);
906 assert_eq!(
907 bash_version("GNU bash, version 5.2.37(1)-release (aarch64)\nmore"),
908 Some("5.2.37(1)-release".into())
909 );
910 assert_eq!(bash_version(""), None);
911 assert_eq!(bash_version("GNU bash"), None);
912 assert_eq!(bash_version("GNU bash, version "), None);
913 }
914}