1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
use std::path::PathBuf;

use defmt_json_schema::v1::JsonFrame as DefmtFrame;
use mantra_schema::{
    coverage::{CoverageSchema, Test, TestRun, TestState},
    traces::TracePk,
};
use regex::Regex;
use time::OffsetDateTime;

#[derive(Debug, thiserror::Error)]
pub enum CoverageError {
    #[error("{}", .0)]
    Fs(#[from] std::io::Error),
    #[error("{}", .0)]
    Deserialize(#[from] serde_json::Error),
    #[error("No tests found.")]
    NoTests,
    #[error("{}", .0)]
    BadDate(String),
    #[error("{}", .0)]
    Match(String),
}

/// Path to text file containing fielpaths to all generated coverage files since last `collect`.
pub const COVERAGES_PATH: &str = "target/coverages.txt";

pub fn coverages_filepath() -> PathBuf {
    crate::path::get_cargo_root().map_or(
        std::env::current_dir().expect("Current directory must always exist."),
        |p| p.join(PathBuf::from(COVERAGES_PATH)),
    )
}

pub fn coverage_from_defmt_frames(
    run_name: String,
    meta: Option<serde_json::Value>,
    frames: &[DefmtFrame],
    logs: Option<String>,
) -> Result<CoverageSchema, CoverageError> {
    if frames.is_empty() {
        return Err(CoverageError::NoTests);
    }

    let timestamp = frames
        .first()
        .expect("At least one frame must be available.")
        .host_timestamp;
    let date = OffsetDateTime::from_unix_timestamp_nanos(timestamp as i128).map_err(|_| {
        CoverageError::BadDate(format!("Timestamp '{timestamp}' is not a valid date."))
    })?;

    let mut test_run = TestRun {
        name: run_name,
        date,
        meta,
        logs,
        tests: Vec::new(),
        nr_of_tests: 0,
    };

    let mut current_test: Option<Test> = None;

    let test_fn_matcher = TEST_FN_MATCHER.get_or_init(|| {
        Regex::new(
            r"^\(\d+/(?<nr_tests>\d+)\)\s(?<state>(?:running)|(?:ignoring))\s`(?<fn_name>.+)`...",
        )
        .expect("Could not create regex matcher for defmt test-fn entries.")
    });

    for frame in frames {
        if let Some(captured_test_fn) = test_fn_matcher.captures(&frame.data) {
            if let Some(mut test) = current_test.take() {
                test.state = TestState::Passed;
                test_run.tests.push(test);
            }

            let nr_tests: u32 = captured_test_fn
                .name("nr_tests")
                .expect("Number of tests from the test-fn was not captured.")
                .as_str()
                .parse()
                .expect("Number of tests must be convertible to u32.");

            test_run.nr_of_tests = nr_tests;

            let fn_state = captured_test_fn
                .name("state")
                .expect("State of the test-fn was not captured.");
            let fn_name = captured_test_fn
                .name("fn_name")
                .expect("Name of the test-fn was not captured.");

            let Some(file) = &frame.location.file else {
                return Err(CoverageError::Match(format!(
                    "Missing file location information for log entry '{}'.",
                    frame.data
                )));
            };
            let Some(line_nr) = frame.location.line else {
                return Err(CoverageError::Match(format!(
                    "Missing line location information for log entry '{}'.",
                    frame.data
                )));
            };
            let Some(mod_path) = &frame.location.module_path else {
                return Err(CoverageError::Match(format!(
                    "Missing line location information for log entry '{}'.",
                    frame.data
                )));
            };
            let mod_path_str = format!(
                "{}{}",
                mod_path.crate_name,
                if mod_path.modules.is_empty() {
                    String::new()
                } else {
                    format!("::{}", mod_path.modules.join("::"))
                }
            );

            let test_fn_name = format!("{}::{}", mod_path_str, fn_name.as_str());

            match fn_state.as_str() {
                "running" => {
                    current_test = Some(Test { name: test_fn_name, filepath: PathBuf::from(file), line: line_nr, state: TestState::Failed, covered_traces: Vec::new(), covered_lines: Vec::new() });
                }
                "ignoring" => {
                    test_run.tests.push(Test{ name: test_fn_name, filepath: PathBuf::from(file), line: line_nr, state: TestState::Skipped { reason: None }, covered_traces: Vec::new(), covered_lines: Vec::new() });

                    current_test = None;
                }
                _ => 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),
            }
        } else if let Some(covered_req) =
            mantra_rust_macros::extract::extract_first_coverage(&frame.data)
        {
            if let Some(test) = &mut current_test {
                test.covered_traces.push(TracePk {
                    req_id: covered_req.id,
                    filepath: covered_req.file,
                    line: covered_req.line,
                });
            }
        } else if frame.data == "all tests passed!" {
            if let Some(mut test) = current_test.take() {
                test.state = TestState::Passed;
                test_run.tests.push(test);
            }
        }
    }

    Ok(CoverageSchema {
        test_runs: vec![test_run],
    })
}

static TEST_FN_MATCHER: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();