embedded_runner/
coverage.rs

1use std::{
2    collections::{HashMap, HashSet},
3    path::PathBuf,
4};
5
6use defmt_json_schema::v1::JsonFrame as DefmtFrame;
7use mantra_schema::{
8    coverage::{CoverageSchema, CoveredFile, CoveredFileTrace, Test, TestRun, TestState},
9    requirements::ReqId,
10    Line,
11};
12use regex::Regex;
13use time::OffsetDateTime;
14
15#[derive(Debug, thiserror::Error)]
16pub enum CoverageError {
17    #[error("{}", .0)]
18    Fs(#[from] std::io::Error),
19    #[error("{}", .0)]
20    Deserialize(#[from] serde_json::Error),
21    #[error("No tests found.")]
22    NoTests,
23    #[error("{}", .0)]
24    BadDate(String),
25    #[error("{}", .0)]
26    Match(String),
27}
28
29/// Path to text file containing fielpaths to all generated coverage files since last `collect`.
30pub const COVERAGES_PATH: &str = "target/coverages.txt";
31
32pub fn coverages_filepath() -> PathBuf {
33    crate::path::get_cargo_root().map_or(
34        std::env::current_dir().expect("Current directory must always exist."),
35        |p| p.join(PathBuf::from(COVERAGES_PATH)),
36    )
37}
38
39pub fn coverage_from_defmt_frames(
40    run_name: String,
41    data: Option<serde_json::Value>,
42    frames: &[DefmtFrame],
43    logs: Option<String>,
44) -> Result<CoverageSchema, CoverageError> {
45    if frames.is_empty() {
46        return Err(CoverageError::NoTests);
47    }
48
49    let timestamp = frames
50        .first()
51        .expect("At least one frame must be available.")
52        .host_timestamp;
53    let date = OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128).map_err(|_| {
54        CoverageError::BadDate(format!("Timestamp '{timestamp}' is not a valid date."))
55    })?;
56
57    let test_fn_matcher = TEST_FN_MATCHER.get_or_init(|| {
58        Regex::new(
59            r"^\(\d+/(?<nr_tests>\d+)\)\s(?<state>(?:running)|(?:ignoring))\s`(?<fn_name>.+)`...",
60        )
61        .expect("Could not create regex matcher for defmt test-fn entries.")
62    });
63
64    let mut test_run = TestRun {
65        name: run_name,
66        date,
67        data,
68        logs,
69        tests: Vec::new(),
70        nr_of_tests: 0,
71    };
72    let mut current_test: Option<Test> = None;
73    let mut covered_traces: HashMap<PathBuf, HashMap<Line, HashSet<ReqId>>> = HashMap::new();
74
75    for frame in frames {
76        if let Some(captured_test_fn) = test_fn_matcher.captures(&frame.data) {
77            if let Some(mut test) = current_test.take() {
78                test.state = TestState::Passed;
79                test.covered_files = drain_covered_traces(&mut covered_traces);
80                test_run.tests.push(test);
81            }
82
83            let nr_tests: u32 = captured_test_fn
84                .name("nr_tests")
85                .expect("Number of tests from the test-fn was not captured.")
86                .as_str()
87                .parse()
88                .expect("Number of tests must be convertible to u32.");
89
90            test_run.nr_of_tests = nr_tests;
91
92            let fn_state = captured_test_fn
93                .name("state")
94                .expect("State of the test-fn was not captured.");
95            let fn_name = captured_test_fn
96                .name("fn_name")
97                .expect("Name of the test-fn was not captured.");
98
99            let Some(file) = &frame.location.file else {
100                return Err(CoverageError::Match(format!(
101                    "Missing file location information for log entry '{}'.",
102                    frame.data
103                )));
104            };
105            let Some(line_nr) = frame.location.line else {
106                return Err(CoverageError::Match(format!(
107                    "Missing line location information for log entry '{}'.",
108                    frame.data
109                )));
110            };
111            let Some(mod_path) = &frame.location.module_path else {
112                return Err(CoverageError::Match(format!(
113                    "Missing line location information for log entry '{}'.",
114                    frame.data
115                )));
116            };
117            let mod_path_str = format!(
118                "{}{}",
119                mod_path.crate_name,
120                if mod_path.modules.is_empty() {
121                    String::new()
122                } else {
123                    format!("::{}", mod_path.modules.join("::"))
124                }
125            );
126
127            let test_fn_name = format!("{}::{}", mod_path_str, fn_name.as_str());
128
129            match fn_state.as_str() {
130                "running" => {
131                    current_test = Some(Test { name: test_fn_name, filepath: PathBuf::from(file), line: line_nr, state: TestState::Failed, covered_files: Vec::new() });
132                }
133                "ignoring" => {
134                    test_run.tests.push(Test{ name: test_fn_name, filepath: PathBuf::from(file), line: line_nr, state: TestState::Skipped { reason: None }, covered_files: Vec::new() });
135
136                    debug_assert_eq!(current_test, None, "Open test state for ignored test.");
137                    debug_assert!(covered_traces.is_empty(), "Covered traces for ignored test.");
138                }
139                _ => unreachable!("Invalid state '{}' for test function '{}' in log entry '{}'. Only 'running' and 'ignoring' are allowed.", fn_state.as_str(), fn_name.as_str(), frame.data),
140            }
141        } else if let Some(covered_req) =
142            mantra_rust_macros::extract::extract_first_coverage(&frame.data)
143        {
144            covered_traces
145                .entry(covered_req.file)
146                .or_default()
147                .entry(covered_req.line)
148                .or_default()
149                .insert(covered_req.id);
150        } else if frame.data == "all tests passed!" {
151            if let Some(mut test) = current_test.take() {
152                test.state = TestState::Passed;
153                test.covered_files = drain_covered_traces(&mut covered_traces);
154                test_run.tests.push(test);
155            }
156        }
157    }
158
159    Ok(CoverageSchema {
160        version: Some(mantra_schema::SCHEMA_VERSION.to_string()),
161        test_runs: vec![test_run],
162    })
163}
164
165fn drain_covered_traces(
166    covered_traces: &mut HashMap<PathBuf, HashMap<Line, HashSet<ReqId>>>,
167) -> Vec<CoveredFile> {
168    let mut covered_files = Vec::new();
169
170    for (filepath, traced_lines) in covered_traces.drain() {
171        covered_files.push(CoveredFile {
172            filepath,
173            covered_traces: traced_lines
174                .into_iter()
175                .map(|(line, req_ids)| CoveredFileTrace {
176                    req_ids: req_ids.into_iter().collect(),
177                    line,
178                })
179                .collect(),
180            covered_lines: Vec::new(),
181        });
182    }
183
184    covered_files
185}
186
187static TEST_FN_MATCHER: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();