Skip to main content

codecov_rust_mcp/
report.rs

1//! Coverage report types and miss-file helpers.
2
3use serde::{Deserialize, Serialize};
4
5/// Aggregated coverage totals for a commit or file.
6#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7pub struct ReportTotals {
8    #[serde(default)]
9    pub files: i64,
10    #[serde(default)]
11    pub lines: i64,
12    #[serde(default)]
13    pub hits: i64,
14    #[serde(default)]
15    pub misses: i64,
16    #[serde(default)]
17    pub partials: i64,
18    #[serde(default)]
19    pub coverage: Option<serde_json::Value>,
20    #[serde(default)]
21    pub branches: i64,
22    #[serde(default)]
23    pub methods: i64,
24}
25
26/// One file entry inside a coverage report.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ReportFile {
29    pub name: String,
30    pub totals: ReportTotals,
31    #[serde(default)]
32    pub line_coverage: Vec<serde_json::Value>,
33}
34
35/// Full Codecov totals response (commit totals plus per-file breakdown).
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct CoverageReport {
38    pub totals: ReportTotals,
39    #[serde(default)]
40    pub files: Vec<ReportFile>,
41    #[serde(default)]
42    pub commit_file_url: Option<String>,
43}
44
45/// Compact miss summary for agent-facing miss lists.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct MissFile {
48    pub name: String,
49    pub misses: i64,
50    pub lines: i64,
51    pub hits: i64,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub coverage: Option<serde_json::Value>,
54}
55
56/// Miss-file list plus commit totals and how many files were returned.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct MissFilesResult {
59    pub totals: ReportTotals,
60    pub files: Vec<MissFile>,
61    pub returned: usize,
62}
63
64/// Files with `misses > 0`, sorted by misses descending.
65///
66/// When `limit` is `0`, no truncate is applied. Otherwise the list is capped to `limit`.
67#[must_use]
68pub fn miss_files_from_report(report: &CoverageReport, limit: usize) -> Vec<MissFile> {
69    let mut out: Vec<MissFile> = report
70        .files
71        .iter()
72        .filter(|f| f.totals.misses > 0)
73        .map(|f| MissFile {
74            name: f.name.clone(),
75            misses: f.totals.misses,
76            lines: f.totals.lines,
77            hits: f.totals.hits,
78            coverage: f.totals.coverage.clone(),
79        })
80        .collect();
81    out.sort_by(|a, b| b.misses.cmp(&a.misses).then_with(|| a.name.cmp(&b.name)));
82    if limit > 0 && out.len() > limit {
83        out.truncate(limit);
84    }
85    out
86}
87
88/// Builds a [`MissFilesResult`] from a coverage report.
89#[must_use]
90pub fn miss_files_result(report: &CoverageReport, limit: usize) -> MissFilesResult {
91    let files = miss_files_from_report(report, limit);
92    let returned = files.len();
93    MissFilesResult {
94        totals: report.totals.clone(),
95        files,
96        returned,
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use serde_json::json;
104
105    fn file(name: &str, misses: i64) -> ReportFile {
106        ReportFile {
107            name: name.to_string(),
108            totals: ReportTotals {
109                misses,
110                lines: 10,
111                hits: 10 - misses,
112                coverage: Some(json!(90.0)),
113                ..ReportTotals::default()
114            },
115            line_coverage: Vec::new(),
116        }
117    }
118
119    #[test]
120    fn miss_files_filters_sorts_and_limits() {
121        let report = CoverageReport {
122            totals: ReportTotals {
123                misses: 20,
124                ..ReportTotals::default()
125            },
126            files: vec![
127                file("a.rs", 0),
128                file("b.rs", 5),
129                file("c.rs", 12),
130                file("d.rs", 3),
131                file("e.rs", 5),
132            ],
133            commit_file_url: None,
134        };
135        let result = miss_files_result(&report, 2);
136        assert_eq!(result.returned, 2);
137        assert_eq!(result.totals.misses, 20);
138        assert_eq!(result.files.len(), 2);
139        assert_eq!(result.files[0].name, "c.rs");
140        assert_eq!(result.files[0].misses, 12);
141        assert_eq!(result.files[0].coverage, Some(json!(90.0)));
142        assert_eq!(result.files[1].name, "b.rs");
143        assert_eq!(result.files[1].misses, 5);
144
145        // Equal misses: name tie-break (`then_with`), then Debug on the result.
146        let tied = miss_files_from_report(&report, 0);
147        assert_eq!(tied.iter().filter(|f| f.misses == 5).count(), 2);
148        let names: Vec<&str> = tied
149            .iter()
150            .filter(|f| f.misses == 5)
151            .map(|f| f.name.as_str())
152            .collect();
153        assert_eq!(names, ["b.rs", "e.rs"]);
154        let debug = format!("{result:?}");
155        assert!(debug.contains("returned"));
156    }
157
158    #[test]
159    fn miss_files_empty_when_no_misses() {
160        let report = CoverageReport {
161            totals: ReportTotals::default(),
162            files: vec![file("ok.rs", 0)],
163            commit_file_url: None,
164        };
165        let result = miss_files_result(&report, 30);
166        assert!(result.files.is_empty());
167        assert_eq!(result.returned, 0);
168    }
169
170    #[test]
171    fn miss_files_limit_zero_returns_all() {
172        let report = CoverageReport {
173            totals: ReportTotals::default(),
174            files: vec![file("a.rs", 1), file("b.rs", 2), file("c.rs", 3)],
175            commit_file_url: None,
176        };
177        let files = miss_files_from_report(&report, 0);
178        assert_eq!(files.len(), 3);
179        assert_eq!(files[0].name, "c.rs");
180        let result = miss_files_result(&report, 0);
181        assert_eq!(result.returned, 3);
182        assert_eq!(result.files.len(), 3);
183    }
184}