majordomus_cli/evidence/coverage.rs
1//! A coverage summary bound to the commit and tree it was measured on.
2//!
3//! `scripts/rust-coverage --summary-json` writes what one coverage run measured: the crate,
4//! the session domain and every file, as covered and total counts per dimension. The file
5//! names no commit, so on its own it is a number about some tree nobody can name. An
6//! [`EvidenceCoverage`] is that summary with the commit it was measured at and the tree the
7//! run's own measurement stated, and it cannot be built without a full commit
8//! ([`CommitId`]). It carries the floors the threshold files state at recording time, read
9//! from the files rather than copied, so a reader can see whether the measurement met them.
10//!
11//! A coverage record is current only for its own commit, and only when both its tree and the
12//! presented tree are clean: a number measured on a modified checkout describes code no
13//! commit holds.
14//!
15//! # A coverage record's lifecycle
16//!
17//! ```
18//! use majordomus_cli::evidence::{CommitId, EvidenceCoverage, EvidenceCoverageFloors, TreeState};
19//!
20//! let dims = r#"{"lines":{"covered":8,"total":10,"percent":80.0},
21//! "functions":{"covered":1,"total":1,"percent":100.0},
22//! "regions":{"covered":3,"total":4,"percent":75.0}}"#;
23//! let summary = format!(
24//! r#"{{"schema":1,"measurement":"scripts/rust-coverage","test_code":"excluded",
25//! "crate":{dims},"domain":{{"lines":{{"covered":8,"total":10,"percent":80.0}},
26//! "functions":{{"covered":1,"total":1,"percent":100.0}},
27//! "regions":{{"covered":3,"total":4,"percent":75.0}},"files":["src/a.rs"],"missing":[]}},
28//! "files":{{"src/a.rs":{dims}}}}}"#
29//! );
30//! let commit = CommitId::parse("0123456789abcdef0123456789abcdef01234567").unwrap();
31//! let floors = EvidenceCoverageFloors { crate_lines: Some(90.0), domain_lines: None };
32//! let cov = EvidenceCoverage::from_summary(&summary, commit, TreeState::Clean, floors).unwrap();
33//!
34//! assert!(cov.is_current_for("0123456789abcdef0123456789abcdef01234567", TreeState::Clean));
35//! assert!(!cov.is_current_for("0123456789abcdef0123456789abcdef01234567", TreeState::Dirty));
36//! assert!(!cov.is_current_for("fedcba9876543210fedcba9876543210fedcba98", TreeState::Clean));
37//! assert_eq!(cov.below_floor(), vec!["crate"]);
38//! ```
39
40use std::collections::BTreeMap;
41use std::path::Path;
42
43use schemars::JsonSchema;
44use serde::{Deserialize, Serialize};
45
46use super::freshness::TreeState;
47
48/// A full commit id: 40 or 64 lowercase hexadecimal digits. The only constructor validates.
49///
50/// An abbreviated id names whichever commit happens to share its prefix today, and an
51/// upper-case one is not what git prints; neither can bind a measurement to one commit, so
52/// neither is a `CommitId`, whether parsed or deserialised.
53///
54/// ```
55/// use majordomus_cli::evidence::CommitId;
56///
57/// let id = CommitId::parse("0123456789abcdef0123456789abcdef01234567").unwrap();
58/// assert_eq!(id.as_str().len(), 40);
59/// assert!(CommitId::parse("0123456789ab").is_err(), "abbreviated");
60/// assert!(serde_json::from_str::<CommitId>("\"ABCDEF0123456789ABCDEF0123456789ABCDEF01\"").is_err());
61/// ```
62#[derive(
63 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
64)]
65#[serde(try_from = "String", into = "String")]
66#[schemars(
67 with = "String",
68 description = "A full commit id: 40 or 64 lowercase hexadecimal digits."
69)]
70pub struct CommitId(String);
71
72impl CommitId {
73 /// The commit id `s` names, or why it is not a full one.
74 ///
75 /// ```
76 /// use majordomus_cli::evidence::CommitId;
77 /// assert!(CommitId::parse(&"a".repeat(64)).is_ok(), "a SHA-256 repository's id");
78 /// assert!(CommitId::parse(&"g".repeat(40)).is_err(), "not hexadecimal");
79 /// ```
80 pub fn parse(s: &str) -> Result<CommitId, String> {
81 let hex = s
82 .bytes()
83 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
84 if (s.len() == 40 || s.len() == 64) && hex {
85 Ok(CommitId(s.to_string()))
86 } else {
87 Err(format!(
88 "`{s}` is not a full commit id: 40 or 64 lowercase hexadecimal digits"
89 ))
90 }
91 }
92
93 /// The id as git prints it.
94 ///
95 /// ```
96 /// use majordomus_cli::evidence::CommitId;
97 /// let full = "0123456789abcdef0123456789abcdef01234567";
98 /// assert_eq!(CommitId::parse(full).unwrap().as_str(), full);
99 /// ```
100 pub fn as_str(&self) -> &str {
101 &self.0
102 }
103}
104
105impl TryFrom<String> for CommitId {
106 type Error = String;
107 fn try_from(s: String) -> Result<CommitId, String> {
108 CommitId::parse(&s)
109 }
110}
111
112impl From<CommitId> for String {
113 fn from(id: CommitId) -> String {
114 id.0
115 }
116}
117
118/// Covered and total items of one dimension, and the percentage the producer computed.
119///
120/// ```
121/// use majordomus_cli::evidence::EvidenceCoverageCount;
122/// let c: EvidenceCoverageCount =
123/// serde_json::from_str(r#"{"covered":3,"total":4,"percent":75.0}"#).unwrap();
124/// assert_eq!((c.covered, c.total, c.percent), (3, 4, 75.0));
125/// ```
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
127pub struct EvidenceCoverageCount {
128 /// Items a test reached.
129 pub covered: u64,
130 /// Items there are.
131 pub total: u64,
132 /// `covered / total` as a percentage, 100 when there is nothing to cover.
133 pub percent: f64,
134}
135
136/// The three dimensions `scripts/rust-coverage` counts, for the crate or one file.
137///
138/// ```
139/// use majordomus_cli::evidence::EvidenceCoverageDimensions;
140/// let one = r#"{"covered":1,"total":2,"percent":50.0}"#;
141/// let d: EvidenceCoverageDimensions = serde_json::from_str(&format!(
142/// r#"{{"lines":{one},"functions":{one},"regions":{one}}}"#
143/// )).unwrap();
144/// assert_eq!(d.lines.total, 2);
145/// ```
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
147pub struct EvidenceCoverageDimensions {
148 /// Executable lines.
149 pub lines: EvidenceCoverageCount,
150 /// Functions.
151 pub functions: EvidenceCoverageCount,
152 /// Code regions.
153 pub regions: EvidenceCoverageCount,
154}
155
156/// The session domain's coverage: the three dimensions over the files the domain lists, and
157/// which of its files the measurement found and which it did not.
158///
159/// ```
160/// use majordomus_cli::evidence::EvidenceCoverageDomain;
161/// let one = r#"{"covered":1,"total":2,"percent":50.0}"#;
162/// let d: EvidenceCoverageDomain = serde_json::from_str(&format!(
163/// r#"{{"lines":{one},"functions":{one},"regions":{one},"files":["a.rs"],"missing":[]}}"#
164/// )).unwrap();
165/// assert_eq!(d.files, vec!["a.rs"]);
166/// ```
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
168pub struct EvidenceCoverageDomain {
169 /// Executable lines of the domain's files.
170 pub lines: EvidenceCoverageCount,
171 /// Functions of the domain's files.
172 pub functions: EvidenceCoverageCount,
173 /// Code regions of the domain's files.
174 pub regions: EvidenceCoverageCount,
175 /// The domain's files the measurement holds.
176 pub files: Vec<String>,
177 /// The domain's files the measurement does not hold.
178 pub missing: Vec<String>,
179}
180
181/// The floors the threshold files stated when the coverage was recorded; a file that is
182/// absent or does not hold a number gives no floor.
183///
184/// ```
185/// use majordomus_cli::evidence::EvidenceCoverageFloors;
186/// let dir = tempfile::tempdir().unwrap();
187/// std::fs::create_dir(dir.path().join("scripts")).unwrap();
188/// std::fs::write(dir.path().join("scripts/rust-coverage-threshold"), "42\n").unwrap();
189/// let floors = EvidenceCoverageFloors::read(dir.path());
190/// assert_eq!((floors.crate_lines, floors.domain_lines), (Some(42.0), None));
191/// ```
192#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
193pub struct EvidenceCoverageFloors {
194 /// The crate's line floor, from `scripts/rust-coverage-threshold`.
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub crate_lines: Option<f64>,
197 /// The domain's line floor, from `scripts/session-coverage-threshold`.
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub domain_lines: Option<f64>,
200}
201
202impl EvidenceCoverageFloors {
203 /// The floors the checkout at `root` states now.
204 ///
205 /// ```
206 /// use majordomus_cli::evidence::EvidenceCoverageFloors;
207 /// let dir = tempfile::tempdir().unwrap();
208 /// assert_eq!(EvidenceCoverageFloors::read(dir.path()), EvidenceCoverageFloors::default());
209 /// ```
210 pub fn read(root: &Path) -> EvidenceCoverageFloors {
211 EvidenceCoverageFloors {
212 crate_lines: floor(&root.join("scripts/rust-coverage-threshold")),
213 domain_lines: floor(&root.join("scripts/session-coverage-threshold")),
214 }
215 }
216}
217
218/// The first number a threshold file holds, after its comments; `None` when it holds none.
219fn floor(path: &Path) -> Option<f64> {
220 let text = std::fs::read_to_string(path).ok()?;
221 text.lines()
222 .map(|l| l.split('#').next().unwrap_or("").trim())
223 .find(|l| !l.is_empty())?
224 .parse::<f64>()
225 .ok()
226 .filter(|f| f.is_finite())
227}
228
229/// One coverage measurement, bound to the commit and tree it was measured on.
230///
231/// Built only by [`EvidenceCoverage::from_summary`] or deserialised; either way `commit` is a
232/// [`CommitId`], so a record without a full commit cannot exist.
233///
234/// ```
235/// use majordomus_cli::evidence::EvidenceCoverage;
236/// let bad = serde_json::json!({"commit": "abc", "working_tree": "clean"});
237/// assert!(serde_json::from_value::<EvidenceCoverage>(bad).is_err());
238/// ```
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
240pub struct EvidenceCoverage {
241 /// The commit the measurement ran at.
242 pub commit: CommitId,
243 /// The tree the coverage run's own measurement stated, `unknown` without one.
244 pub working_tree: TreeState,
245 /// The producer of the summary: `scripts/rust-coverage`.
246 pub measurement: String,
247 /// Whether test code is counted: `excluded`.
248 pub test_code: String,
249 /// The whole crate.
250 #[serde(rename = "crate")]
251 pub whole_crate: EvidenceCoverageDimensions,
252 /// The session domain.
253 pub domain: EvidenceCoverageDomain,
254 /// Every file with executable code under test, by repository-relative path.
255 pub files: BTreeMap<String, EvidenceCoverageDimensions>,
256 /// The floors stated when it was recorded.
257 #[serde(default)]
258 pub floors: EvidenceCoverageFloors,
259}
260
261/// The summary as `scripts/rust-coverage --summary-json` writes it (schema 1).
262#[derive(Deserialize)]
263struct Summary {
264 measurement: String,
265 test_code: String,
266 #[serde(rename = "crate")]
267 whole_crate: EvidenceCoverageDimensions,
268 domain: EvidenceCoverageDomain,
269 files: BTreeMap<String, EvidenceCoverageDimensions>,
270}
271
272impl EvidenceCoverage {
273 /// The summary `text` bound to `commit` and `working_tree`, with `floors`; refused when
274 /// the text is not a schema-1 summary.
275 ///
276 /// ```
277 /// use majordomus_cli::evidence::{CommitId, EvidenceCoverage, TreeState};
278 /// let id = CommitId::parse(&"a".repeat(40)).unwrap();
279 /// let err = EvidenceCoverage::from_summary(r#"{"schema":2}"#, id, TreeState::Clean,
280 /// Default::default()).unwrap_err();
281 /// assert!(err.contains("schema"), "{err}");
282 /// ```
283 pub fn from_summary(
284 text: &str,
285 commit: CommitId,
286 working_tree: TreeState,
287 floors: EvidenceCoverageFloors,
288 ) -> Result<EvidenceCoverage, String> {
289 let schema = serde_json::from_str::<serde_json::Value>(text)
290 .map_err(|e| format!("not a coverage summary: {e}"))?
291 .get("schema")
292 .and_then(|s| s.as_u64());
293 if schema != Some(1) {
294 return Err(format!(
295 "a coverage summary of schema {} is not one this recorder reads (schema 1)",
296 schema.map_or("none".to_string(), |s| s.to_string())
297 ));
298 }
299 let s: Summary =
300 serde_json::from_str(text).map_err(|e| format!("not a coverage summary: {e}"))?;
301 Ok(EvidenceCoverage {
302 commit,
303 working_tree,
304 measurement: s.measurement,
305 test_code: s.test_code,
306 whole_crate: s.whole_crate,
307 domain: s.domain,
308 files: s.files,
309 floors,
310 })
311 }
312
313 /// Whether this measurement describes `commit` as presented: only its own commit, and
314 /// only when its tree and the presented tree are both clean.
315 ///
316 /// ```
317 /// # use majordomus_cli::evidence::{CommitId, EvidenceCoverage, TreeState};
318 /// # let one = r#"{"covered":1,"total":1,"percent":100.0}"#;
319 /// # let dims = format!(r#"{{"lines":{one},"functions":{one},"regions":{one}}}"#);
320 /// # let text = format!(r#"{{"schema":1,"measurement":"m","test_code":"excluded",
321 /// # "crate":{dims},"domain":{{"lines":{one},"functions":{one},"regions":{one},
322 /// # "files":[],"missing":[]}},"files":{{}}}}"#);
323 /// let c = "b".repeat(40);
324 /// let cov = EvidenceCoverage::from_summary(&text, CommitId::parse(&c).unwrap(),
325 /// TreeState::Unknown, Default::default()).unwrap();
326 /// assert!(!cov.is_current_for(&c, TreeState::Clean), "its own tree was not measured");
327 /// ```
328 pub fn is_current_for(&self, commit: &str, presented_tree: TreeState) -> bool {
329 self.commit.as_str() == commit
330 && self.working_tree == TreeState::Clean
331 && presented_tree == TreeState::Clean
332 }
333
334 /// Which of the crate and the domain are below the line floor recorded with them.
335 ///
336 /// ```
337 /// # use majordomus_cli::evidence::{CommitId, EvidenceCoverage, EvidenceCoverageFloors, TreeState};
338 /// # let one = r#"{"covered":1,"total":2,"percent":50.0}"#;
339 /// # let dims = format!(r#"{{"lines":{one},"functions":{one},"regions":{one}}}"#);
340 /// # let text = format!(r#"{{"schema":1,"measurement":"m","test_code":"excluded",
341 /// # "crate":{dims},"domain":{{"lines":{one},"functions":{one},"regions":{one},
342 /// # "files":[],"missing":[]}},"files":{{}}}}"#);
343 /// let floors = EvidenceCoverageFloors { crate_lines: Some(40.0), domain_lines: Some(60.0) };
344 /// let cov = EvidenceCoverage::from_summary(&text, CommitId::parse(&"c".repeat(40)).unwrap(),
345 /// TreeState::Clean, floors).unwrap();
346 /// assert_eq!(cov.below_floor(), vec!["domain"]);
347 /// ```
348 pub fn below_floor(&self) -> Vec<&'static str> {
349 let under = |got: &EvidenceCoverageCount, floor: Option<f64>| {
350 floor.is_some_and(|f| got.percent < f)
351 };
352 let mut out = Vec::new();
353 if under(&self.whole_crate.lines, self.floors.crate_lines) {
354 out.push("crate");
355 }
356 if under(&self.domain.lines, self.floors.domain_lines) {
357 out.push("domain");
358 }
359 out
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 const C1: &str = "0123456789abcdef0123456789abcdef01234567";
368 const C2: &str = "fedcba9876543210fedcba9876543210fedcba98";
369
370 fn summary(schema: u32, crate_pct: f64, domain_pct: f64) -> String {
371 let n = |p: f64| format!(r#"{{"covered":1,"total":2,"percent":{p}}}"#);
372 let dims = |p: f64| {
373 format!(
374 r#"{{"lines":{},"functions":{},"regions":{}}}"#,
375 n(p),
376 n(p),
377 n(p)
378 )
379 };
380 format!(
381 r#"{{"schema":{schema},"measurement":"scripts/rust-coverage","test_code":"excluded",
382 "crate":{},"domain":{{"lines":{},"functions":{},"regions":{},
383 "files":["src/a.rs"],"missing":["src/b.rs"]}},"files":{{"src/a.rs":{}}}}}"#,
384 dims(crate_pct),
385 n(domain_pct),
386 n(domain_pct),
387 n(domain_pct),
388 dims(crate_pct)
389 )
390 }
391
392 fn cov(tree: TreeState, floors: EvidenceCoverageFloors) -> EvidenceCoverage {
393 let id = CommitId::parse(C1).unwrap();
394 EvidenceCoverage::from_summary(&summary(1, 50.0, 70.0), id, tree, floors).unwrap()
395 }
396
397 #[test]
398 fn a_coverage_record_cannot_be_built_without_a_full_commit() {
399 for bad in [
400 "",
401 "0123456789ab",
402 &C1.to_uppercase(),
403 &"z".repeat(40),
404 &"a".repeat(41),
405 ] {
406 assert!(CommitId::parse(bad).is_err(), "{bad:?}");
407 assert!(
408 serde_json::from_value::<CommitId>(bad.into()).is_err(),
409 "{bad:?}"
410 );
411 }
412 let mut v = serde_json::to_value(cov(TreeState::Clean, Default::default())).unwrap();
413 assert_eq!(v["commit"], C1);
414 v["commit"] = "0123456".into();
415 assert!(serde_json::from_value::<EvidenceCoverage>(v).is_err());
416 }
417
418 #[test]
419 fn a_summary_of_another_schema_is_refused() {
420 let id = || CommitId::parse(C1).unwrap();
421 let err = EvidenceCoverage::from_summary(
422 &summary(2, 1.0, 1.0),
423 id(),
424 TreeState::Clean,
425 Default::default(),
426 );
427 assert!(err.unwrap_err().contains("schema 2"));
428 let err = EvidenceCoverage::from_summary("{}", id(), TreeState::Clean, Default::default());
429 assert!(err.unwrap_err().contains("schema none"));
430 let err =
431 EvidenceCoverage::from_summary("not json", id(), TreeState::Clean, Default::default());
432 assert!(err.unwrap_err().contains("not a coverage summary"));
433 let err = EvidenceCoverage::from_summary(
434 r#"{"schema":1}"#,
435 id(),
436 TreeState::Clean,
437 Default::default(),
438 );
439 assert!(err.unwrap_err().contains("not a coverage summary"));
440 }
441
442 #[test]
443 fn coverage_is_current_only_for_its_commit_on_clean_trees() {
444 let trees = [TreeState::Clean, TreeState::Dirty, TreeState::Unknown];
445 for own in trees {
446 let c = cov(own, Default::default());
447 for presented in trees {
448 for commit in [C1, C2] {
449 let expect =
450 commit == C1 && own == TreeState::Clean && presented == TreeState::Clean;
451 assert_eq!(
452 c.is_current_for(commit, presented),
453 expect,
454 "{own:?} {presented:?} {commit}"
455 );
456 }
457 }
458 }
459 }
460
461 #[test]
462 fn floors_are_read_from_the_threshold_files() {
463 let dir = tempfile::tempdir().unwrap();
464 std::fs::create_dir(dir.path().join("scripts")).unwrap();
465 std::fs::write(
466 dir.path().join("scripts/rust-coverage-threshold"),
467 "# floor\n61.5\n",
468 )
469 .unwrap();
470 std::fs::write(
471 dir.path().join("scripts/session-coverage-threshold"),
472 "72 # domain\n",
473 )
474 .unwrap();
475 let f = EvidenceCoverageFloors::read(dir.path());
476 assert_eq!((f.crate_lines, f.domain_lines), (Some(61.5), Some(72.0)));
477 }
478
479 #[test]
480 fn an_absent_or_unreadable_floor_is_none() {
481 let dir = tempfile::tempdir().unwrap();
482 std::fs::create_dir(dir.path().join("scripts")).unwrap();
483 std::fs::write(dir.path().join("scripts/rust-coverage-threshold"), "high\n").unwrap();
484 std::fs::create_dir(dir.path().join("scripts/session-coverage-threshold")).unwrap();
485 assert_eq!(
486 EvidenceCoverageFloors::read(dir.path()),
487 EvidenceCoverageFloors::default()
488 );
489 let json = serde_json::to_value(EvidenceCoverageFloors::default()).unwrap();
490 assert_eq!(json, serde_json::json!({}), "an absent floor is left out");
491 }
492
493 #[test]
494 fn below_floor_names_what_is_under() {
495 let at = |c, d| EvidenceCoverageFloors {
496 crate_lines: c,
497 domain_lines: d,
498 };
499 assert_eq!(
500 cov(TreeState::Clean, at(Some(50.0), Some(70.0))).below_floor(),
501 Vec::<&str>::new()
502 );
503 assert_eq!(
504 cov(TreeState::Clean, at(Some(50.1), None)).below_floor(),
505 vec!["crate"]
506 );
507 assert_eq!(
508 cov(TreeState::Clean, at(None, Some(70.1))).below_floor(),
509 vec!["domain"]
510 );
511 assert_eq!(
512 cov(TreeState::Clean, at(Some(99.0), Some(99.0))).below_floor(),
513 vec!["crate", "domain"]
514 );
515 assert_eq!(
516 cov(TreeState::Clean, at(None, None)).below_floor(),
517 Vec::<&str>::new()
518 );
519 }
520
521 /// A threshold file that holds only comments and blank lines states no floor.
522 #[test]
523 fn a_threshold_file_of_comments_states_no_floor() {
524 let dir = tempfile::tempdir().unwrap();
525 std::fs::create_dir_all(dir.path().join("scripts")).unwrap();
526 std::fs::write(
527 dir.path().join("scripts/rust-coverage-threshold"),
528 "# the floor\n\n # moved\n",
529 )
530 .unwrap();
531 std::fs::write(
532 dir.path().join("scripts/session-coverage-threshold"),
533 "90 # ok\n",
534 )
535 .unwrap();
536 let floors = EvidenceCoverageFloors::read(dir.path());
537 assert_eq!(floors.crate_lines, None);
538 assert_eq!(floors.domain_lines, Some(90.0));
539 }
540}