Skip to main content

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}