Skip to content

Commit 62051a3

Browse files
committed
feat: add --skip-coverage-path
1 parent 108c40f commit 62051a3

13 files changed

Lines changed: 253 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,26 @@
1+
# 0.5.0 - 2026-02-18
2+
3+
Added
4+
- `--skip-coverage-path` regex option to exclude matching paths from uncovered line calculations and list skipped paths in the summary output
5+
6+
Changed
7+
- CLI output lines order
8+
9+
# 0.4.1 - 2026-02-15
10+
11+
Fixed
12+
- Bump version in Cargo.toml
13+
14+
# 0.4.0 - 2026-02-15
15+
16+
Changed
17+
- Change entrypoint to cmd to simplify CI usage
18+
19+
# 0.3.2 - 2026-02-11
20+
21+
Fixed
22+
- dockerhub release
23+
124
# 0.3.1 - 2026-02-11
225

326
Fixed

Cargo.lock

Lines changed: 40 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "diff-coverage"
3-
version = "0.4.1"
3+
version = "0.5.0"
44
edition = "2021"
55
description = "Diff-coverage, supercharged in Rust. Fast, memory-efficient coverage on changed lines for CI."
66
license = "MIT"
@@ -13,5 +13,6 @@ categories = ["development-tools::testing", "command-line-utilities"]
1313
clap = { version = "4.5.7", features = ["derive"] }
1414
owo-colors = "4.0.0"
1515
quick-xml = "0.37.0"
16+
regex = "1.10.6"
1617
serde = { version = "1.0.210", features = ["derive"] }
1718
serde_json = "1.0.128"

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,16 @@ diff-coverage ./coverage/ coverage.xml --diff-file diff.diff --fail-under 80
2121
# Output to CI formats
2222
diff-coverage coverage.xml --diff-file diff.diff --output gitlab=diff-cover.json
2323
diff-coverage coverage.xml --diff-file diff.diff --output json=diff-cover.json --output summary
24+
25+
# Skip external packages from uncovered line calculations (regex supported)
26+
diff-coverage coverage.xml --diff-file diff.diff --skip-coverage-path '^pkg/mongodb'
2427
```
2528

2629
Options
2730
- --diff-file <PATH>: diff to analyze
2831
- --fail-under <PERCENT>: minimum acceptable diff coverage
2932
- --missing-coverage <MODE>: how to handle files missing from coverage (uncovered or ignore, default: ignore)
33+
- --skip-coverage-path <REGEX>: regex of file paths to skip from uncovered line calculations (repeatable or comma-separated)
3034
- --output <FORMAT=PATH>: output target(s), repeatable or comma‑separated Formats: cli, summary, gitlab, json (note: cli and summary don’t take a path)
3135
- -h, --help: show help
3236
- -V, --version: show version

src/app/mod.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub fn run(options: cli::CliOptions) -> Result<(), AppError> {
1717
let fail_under = options.fail_under;
1818
let output_targets = options.outputs;
1919
let missing_coverage = options.missing_coverage;
20+
let skip_coverage_paths = options.skip_coverage_paths;
2021

2122
let coverage_files = collect_coverage_files(coverage_paths).map_err(AppError::usage)?;
2223

@@ -39,9 +40,13 @@ pub fn run(options: cli::CliOptions) -> Result<(), AppError> {
3940

4041
let treat_missing_as_uncovered =
4142
matches!(missing_coverage, cli::MissingCoverageMode::Uncovered);
42-
let report =
43-
coverage::analyze_changed_coverage(&changed, &coverage, treat_missing_as_uncovered)
44-
.map_err(|err| AppError::usage(err.to_string()))?;
43+
let report = coverage::analyze_changed_coverage(
44+
&changed,
45+
&coverage,
46+
treat_missing_as_uncovered,
47+
&skip_coverage_paths,
48+
)
49+
.map_err(|err| AppError::usage(err.to_string()))?;
4550
write_reports(&report, &output_plan)?;
4651

4752
if let Some(threshold) = fail_under {

src/cli/mod.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::path::PathBuf;
33
use std::sync::OnceLock;
44

55
use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum, ValueHint};
6+
use regex::Regex;
67

78
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
89
#[value(rename_all = "kebab_case")]
@@ -101,6 +102,14 @@ pub struct CliOptions {
101102
help = "How to handle files missing from coverage: uncovered or ignore"
102103
)]
103104
pub missing_coverage: MissingCoverageMode,
105+
#[arg(
106+
long = "skip-coverage-path",
107+
value_name = "REGEX",
108+
help = "Regex of file paths to exclude from uncovered line calculations; repeatable or comma-separated",
109+
action = clap::ArgAction::Append,
110+
value_delimiter = ','
111+
)]
112+
pub skip_coverage_paths: Vec<Regex>,
104113
#[arg(
105114
long = "output",
106115
id = "output",
@@ -326,4 +335,32 @@ mod tests {
326335
.expect("parse");
327336
assert_eq!(options.missing_coverage, MissingCoverageMode::Ignore);
328337
}
338+
339+
#[test]
340+
fn parses_skip_coverage_paths() {
341+
let options = parse_args([
342+
OsString::from("bin"),
343+
OsString::from("--skip-coverage-path"),
344+
OsString::from("^pkg/mongodb"),
345+
OsString::from("--skip-coverage-path"),
346+
OsString::from("vendor/.*"),
347+
])
348+
.expect("parse");
349+
assert_eq!(options.skip_coverage_paths.len(), 2);
350+
assert_eq!(options.skip_coverage_paths[0].as_str(), "^pkg/mongodb");
351+
assert_eq!(options.skip_coverage_paths[1].as_str(), "vendor/.*");
352+
}
353+
354+
#[test]
355+
fn parses_skip_coverage_paths_comma_separated() {
356+
let options = parse_args([
357+
OsString::from("bin"),
358+
OsString::from("--skip-coverage-path"),
359+
OsString::from("^pkg/mongodb,vendor/.*"),
360+
])
361+
.expect("parse");
362+
assert_eq!(options.skip_coverage_paths.len(), 2);
363+
assert_eq!(options.skip_coverage_paths[0].as_str(), "^pkg/mongodb");
364+
assert_eq!(options.skip_coverage_paths[1].as_str(), "vendor/.*");
365+
}
329366
}

src/coverage/clover.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ mod tests {
214214
.parse(cov_file, &mut store)
215215
.expect("parse clover");
216216

217-
let report = crate::coverage::analyze_changed_coverage(&changed, &store, true);
217+
let report = crate::coverage::analyze_changed_coverage(&changed, &store, true, &[]);
218218
let report = report.expect("report");
219219
let calc = report
220220
.uncovered_files

src/coverage/mod.rs

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use std::io::Read;
77
use std::collections::BTreeSet;
88

99
use crate::diff::types::ChangedFile;
10-
use crate::report::{CoverageReport, UncoveredFile};
10+
use crate::report::{CoverageReport, SkippedFile, UncoveredFile};
1111
use crate::util::path::normalize_path;
12+
use regex::Regex;
1213
use store::CoverageStore;
1314

1415
pub trait CoverageParser {
@@ -25,8 +26,10 @@ pub fn analyze_changed_coverage(
2526
changed_files: &[ChangedFile],
2627
coverage: &CoverageStore,
2728
treat_missing_as_uncovered: bool,
29+
skip_paths: &[Regex],
2830
) -> Result<CoverageReport, store::CoverageLookupError> {
2931
let mut uncovered_files = Vec::new();
32+
let mut skipped_files = Vec::new();
3033
let mut total_changed = 0usize;
3134
let mut total_covered = 0usize;
3235

@@ -36,6 +39,16 @@ pub fn analyze_changed_coverage(
3639
if unique_lines.is_empty() {
3740
continue;
3841
}
42+
if let Some(matched) = skip_paths
43+
.iter()
44+
.find(|pattern| pattern.is_match(&normalized_path))
45+
{
46+
skipped_files.push(SkippedFile {
47+
path: normalized_path,
48+
pattern: matched.as_str().to_string(),
49+
});
50+
continue;
51+
}
3952

4053
let file_coverage = coverage.file_coverage(&normalized_path)?;
4154
let mut uncovered_lines = Vec::new();
@@ -77,6 +90,7 @@ pub fn analyze_changed_coverage(
7790
total_changed,
7891
total_covered,
7992
uncovered_files,
93+
skipped_files,
8094
})
8195
}
8296

@@ -86,6 +100,7 @@ mod tests {
86100
use crate::coverage::store::CoverageStore;
87101
use crate::coverage::CoverageSink;
88102
use crate::diff::types::ChangedFile;
103+
use regex::Regex;
89104

90105
#[test]
91106
fn counts_unique_changed_lines_and_tracks_uncovered() {
@@ -98,11 +113,12 @@ mod tests {
98113
store.on_line("src/foo.rs", 1, 2);
99114
store.on_line("src/foo.rs", 2, 0);
100115

101-
let report = analyze_changed_coverage(&changed_files, &store, true).expect("report");
116+
let report = analyze_changed_coverage(&changed_files, &store, true, &[]).expect("report");
102117

103118
assert_eq!(report.total_changed, 2);
104119
assert_eq!(report.total_covered, 1);
105120
assert_eq!(report.uncovered_files.len(), 1);
121+
assert!(report.skipped_files.is_empty());
106122
let uncovered = &report.uncovered_files[0];
107123
assert_eq!(uncovered.path, "src/foo.rs");
108124
assert_eq!(uncovered.uncovered_lines, vec![2]);
@@ -116,11 +132,12 @@ mod tests {
116132
}];
117133

118134
let store = CoverageStore::default();
119-
let report = analyze_changed_coverage(&changed_files, &store, true).expect("report");
135+
let report = analyze_changed_coverage(&changed_files, &store, true, &[]).expect("report");
120136

121137
assert_eq!(report.total_changed, 2);
122138
assert_eq!(report.total_covered, 0);
123139
assert_eq!(report.uncovered_files.len(), 1);
140+
assert!(report.skipped_files.is_empty());
124141
let uncovered = &report.uncovered_files[0];
125142
assert_eq!(uncovered.path, "src/foo.rs");
126143
assert_eq!(uncovered.uncovered_lines, vec![1, 2]);
@@ -134,10 +151,40 @@ mod tests {
134151
}];
135152

136153
let store = CoverageStore::default();
137-
let report = analyze_changed_coverage(&changed_files, &store, false).expect("report");
154+
let report = analyze_changed_coverage(&changed_files, &store, false, &[]).expect("report");
138155

139156
assert_eq!(report.total_changed, 0);
140157
assert_eq!(report.total_covered, 0);
141158
assert!(report.uncovered_files.is_empty());
159+
assert!(report.skipped_files.is_empty());
160+
}
161+
162+
#[test]
163+
fn skips_files_matching_patterns() {
164+
let changed_files = vec![
165+
ChangedFile {
166+
path: "pkg/mongodb/client.go".to_string(),
167+
changed_lines: vec![1, 2],
168+
},
169+
ChangedFile {
170+
path: "src/main.go".to_string(),
171+
changed_lines: vec![10],
172+
},
173+
];
174+
175+
let mut store = CoverageStore::default();
176+
store.on_line("src/main.go", 10, 1);
177+
178+
let patterns = [Regex::new(r"^pkg/mongodb").expect("regex")];
179+
let report = analyze_changed_coverage(&changed_files, &store, true, &patterns)
180+
.expect("report");
181+
182+
assert_eq!(report.total_changed, 1);
183+
assert_eq!(report.total_covered, 1);
184+
assert!(report.uncovered_files.is_empty());
185+
assert_eq!(report.skipped_files.len(), 1);
186+
let skipped = &report.skipped_files[0];
187+
assert_eq!(skipped.path, "pkg/mongodb/client.go");
188+
assert_eq!(skipped.pattern, r"^pkg/mongodb");
142189
}
143190
}

src/report/console.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,21 @@ pub fn render_to<W: Write + ?Sized>(
5151
}
5252
}
5353

54+
if !report.skipped_files.is_empty() {
55+
writeln!(out)?;
56+
let header = "Skipped paths from uncovered line calculation (matched --skip-coverage-path):";
57+
if use_color {
58+
writeln!(out, "{}", header.yellow().bold())?;
59+
} else {
60+
writeln!(out, "{header}")?;
61+
}
62+
for skipped in &report.skipped_files {
63+
writeln!(out, "- {} (matched {})", skipped.path, skipped.pattern)?;
64+
}
65+
}
66+
67+
writeln!(out)?;
68+
writeln!(out, "-------------")?;
5469
writeln!(out, "Coverage for changed lines: {percent_display}")?;
5570
out.flush()
5671
}
@@ -108,7 +123,8 @@ fn push_range(ranges: &mut Vec<String>, start: u32, end: u32) {
108123

109124
#[cfg(test)]
110125
mod tests {
111-
use super::format_line_ranges;
126+
use super::{format_line_ranges, render_to};
127+
use crate::report::{CoverageReport, SkippedFile};
112128

113129
#[test]
114130
fn formats_line_ranges() {
@@ -117,4 +133,25 @@ mod tests {
117133
assert_eq!(format_line_ranges(&[1, 2, 3, 5]), "1-3, 5");
118134
assert_eq!(format_line_ranges(&[1, 3, 5, 6, 7, 8, 9]), "1, 3, 5-9");
119135
}
136+
137+
#[test]
138+
fn includes_skipped_paths() {
139+
let report = CoverageReport {
140+
total_changed: 1,
141+
total_covered: 1,
142+
uncovered_files: Vec::new(),
143+
skipped_files: vec![SkippedFile {
144+
path: "pkg/mongodb/client.go".to_string(),
145+
pattern: "^pkg/mongodb".to_string(),
146+
}],
147+
};
148+
149+
let mut out = Vec::new();
150+
render_to(&report, &mut out, false).expect("render");
151+
let text = String::from_utf8(out).expect("utf8");
152+
153+
assert!(text.contains("Skipped paths"));
154+
assert!(text.contains("pkg/mongodb/client.go"));
155+
assert!(text.contains("^pkg/mongodb"));
156+
}
120157
}

0 commit comments

Comments
 (0)