embedded_runner/
coverage.rs1use 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
29pub 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();