padded_cell 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/Cargo.lock +2 -2
  4. data/README.md +148 -7
  5. data/ext/padded_cell_native/core/Cargo.toml +1 -1
  6. data/ext/padded_cell_native/core/src/chart/directives/tests.rs +56 -0
  7. data/ext/padded_cell_native/core/src/chart/directives.rs +144 -0
  8. data/ext/padded_cell_native/core/src/chart/json/tests.rs +46 -0
  9. data/ext/padded_cell_native/core/src/chart/json.rs +175 -0
  10. data/ext/padded_cell_native/core/src/chart/lcov.rs +50 -0
  11. data/ext/padded_cell_native/core/src/chart/markdown/tests.rs +93 -0
  12. data/ext/padded_cell_native/core/src/chart/markdown.rs +201 -0
  13. data/ext/padded_cell_native/core/src/chart/methods/tests.rs +38 -0
  14. data/ext/padded_cell_native/core/src/chart/methods.rs +62 -0
  15. data/ext/padded_cell_native/core/src/chart/output/tests.rs +45 -0
  16. data/ext/padded_cell_native/core/src/chart/output.rs +103 -0
  17. data/ext/padded_cell_native/core/src/chart/results/tests.rs +96 -0
  18. data/ext/padded_cell_native/core/src/chart/results.rs +379 -0
  19. data/ext/padded_cell_native/core/src/chart/source/tests.rs +64 -0
  20. data/ext/padded_cell_native/core/src/chart/source.rs +153 -0
  21. data/ext/padded_cell_native/core/src/chart/stats/tests.rs +52 -0
  22. data/ext/padded_cell_native/core/src/chart/stats.rs +218 -0
  23. data/ext/padded_cell_native/core/src/chart/test_support.rs +93 -0
  24. data/ext/padded_cell_native/core/src/chart/tests.rs +97 -0
  25. data/ext/padded_cell_native/core/src/chart/thresholds/tests.rs +36 -0
  26. data/ext/padded_cell_native/core/src/chart/thresholds.rs +116 -0
  27. data/ext/padded_cell_native/core/src/chart.rs +302 -0
  28. data/ext/padded_cell_native/core/src/discover/tests.rs +2 -0
  29. data/ext/padded_cell_native/core/src/discover.rs +7 -53
  30. data/ext/padded_cell_native/core/src/error.rs +24 -0
  31. data/ext/padded_cell_native/core/src/lib.rs +5 -1
  32. data/ext/padded_cell_native/core/src/parallel.rs +43 -0
  33. data/ext/padded_cell_native/ffi/Cargo.toml +1 -1
  34. data/ext/padded_cell_native/ffi/src/chart.rs +205 -0
  35. data/ext/padded_cell_native/ffi/src/lib.rs +3 -0
  36. data/lib/padded_cell/asylum.rb +91 -0
  37. data/lib/padded_cell/cell.rb +14 -8
  38. data/lib/padded_cell/chart/accumulator.rb +103 -0
  39. data/lib/padded_cell/chart/config.rb +129 -0
  40. data/lib/padded_cell/chart/delta.rb +148 -0
  41. data/lib/padded_cell/chart/document.rb +64 -0
  42. data/lib/padded_cell/chart/filter.rb +55 -0
  43. data/lib/padded_cell/chart/merge.rb +63 -0
  44. data/lib/padded_cell/chart/probe.rb +70 -0
  45. data/lib/padded_cell/chart/raw.rb +48 -0
  46. data/lib/padded_cell/chart/report.rb +128 -0
  47. data/lib/padded_cell/chart/source_files.rb +90 -0
  48. data/lib/padded_cell/chart.rb +38 -0
  49. data/lib/padded_cell/cli.rb +59 -4
  50. data/lib/padded_cell/loader.rb +5 -1
  51. data/lib/padded_cell/pool.rb +19 -42
  52. data/lib/padded_cell/reporters.rb +20 -3
  53. data/lib/padded_cell/results.rb +13 -0
  54. data/lib/padded_cell/runner.rb +125 -22
  55. data/lib/padded_cell/version.rb +1 -1
  56. data/lib/padded_cell/worker.rb +63 -0
  57. data/lib/padded_cell.rb +8 -0
  58. metadata +39 -1
@@ -0,0 +1,50 @@
1
+ use std::io::{self, Write};
2
+
3
+ use super::FileReport;
4
+
5
+ /// Writes `reports` as an LCOV tracefile:
6
+ ///
7
+ /// ```text
8
+ /// SF:./app/models/user.rb
9
+ /// DA:3,1 each line of code outside :nocov: blocks
10
+ /// LF:12 lines of code, those in :nocov: blocks included
11
+ /// LH:9 lines covered
12
+ /// BRDA:4,0,1,2 each branch outside blocks: line, 0, number, hits
13
+ /// BRDA:6,0,2,- ("-" for none)
14
+ /// end_of_record
15
+ /// ```
16
+ ///
17
+ /// An empty `DA` or `BRDA` section leaves an empty line.
18
+ ///
19
+ /// # Errors
20
+ ///
21
+ /// What writing to `out` returns.
22
+ pub fn write_lcov(reports: &[FileReport<'_>], out: &mut impl Write) -> io::Result<()> {
23
+ for report in reports {
24
+ writeln!(out, "SF:./{}", report.file.name)?;
25
+ let (mut code, mut covered, mut first) = (0_u64, 0_u64, true);
26
+ for line in report.lines() {
27
+ code = code.strict_add(u64::from(line.hits.is_some()));
28
+ covered = covered.strict_add(u64::from(line.covered()));
29
+ if let Some(hits) = line.hits
30
+ && !line.skipped
31
+ {
32
+ if !first {
33
+ out.write_all(b"\n")?;
34
+ }
35
+ first = false;
36
+ write!(out, "DA:{},{hits}", line.number)?;
37
+ }
38
+ }
39
+ write!(out, "\nLF:{code}\nLH:{covered}\n")?;
40
+ for (number, branch) in (1_u64..).zip(report.reported_branches()) {
41
+ if number > 1 {
42
+ out.write_all(b"\n")?;
43
+ }
44
+ write!(out, "BRDA:{},0,{number},", branch.report_line())?;
45
+ if branch.hits == 0 { out.write_all(b"-")? } else { write!(out, "{}", branch.hits)? }
46
+ }
47
+ out.write_all(b"\nend_of_record\n")?;
48
+ }
49
+ Ok(())
50
+ }
@@ -0,0 +1,93 @@
1
+ use std::fs;
2
+
3
+ use tempfile::TempDir;
4
+
5
+ use super::super::FileCoverage;
6
+ use super::super::test_support::{calc, reports, summary};
7
+ use super::*;
8
+
9
+ /// Calc misses two branches; gap.rb misses a line holding backticks, and
10
+ /// one line further down.
11
+ #[test]
12
+ fn totals_groups_then_the_code_that_never_ran() {
13
+ let dir = TempDir::new().unwrap();
14
+ let gap = dir.path().join("gap.rb");
15
+ fs::write(&gap, "def a\n 1\nend\n\ndef b\n x = \"```\"\n :y\nend\n\n:z\n").unwrap();
16
+ let mut lines =
17
+ vec![Some(1), Some(1), None, None, Some(1), Some(0), Some(0), None, None, Some(0)];
18
+ lines.truncate(10);
19
+ let files = [
20
+ calc(&dir),
21
+ FileCoverage {
22
+ path: gap,
23
+ name: "lib/gap.rb".to_owned(),
24
+ lines,
25
+ branches: Some(Vec::new()),
26
+ methods: None,
27
+ loaded: true,
28
+ },
29
+ ];
30
+ let reports = reports(&files);
31
+ let mut summary = summary(&reports, &[Criterion::Line, Criterion::Branch]);
32
+ summary.groups =
33
+ vec![("Core".to_owned(), vec![0], super::super::Totals::of_files(&reports[..1]))];
34
+ summary.meta.project_name = "App".to_owned();
35
+ summary.meta.timestamp = "2026-10-11T13:00:00.000+02:00".to_owned();
36
+ summary.meta.commit = Some("0123456789abcdef".to_owned());
37
+ let mut out = Vec::new();
38
+ write_markdown(&summary, &mut out).unwrap();
39
+
40
+ let expected = "\
41
+ # Coverage: App
42
+
43
+ Lines 75.00% (9 / 12) · Branches 50.00% (2 / 4)
44
+
45
+ Generated 2026-10-11T13:00:00.000+02:00 by padded_cell 0.1.0, at `0123456789ab`.
46
+
47
+ | Group | Lines | Branches |
48
+ |---|---:|---:|
49
+ | Core | 100.00% (6 / 6) | 50.00% (2 / 4) |
50
+
51
+ ## Missed, most first
52
+
53
+ ### `lib/gap.rb`
54
+
55
+ Lines 50.00% (3 / 6) · Branches 100.00% (0 / 0)
56
+
57
+ Lines never run: 6-7, 10.
58
+
59
+ ````ruby
60
+ 6 x = \"```\"
61
+ 7 :y
62
+ …
63
+ 10 :z
64
+ ````
65
+
66
+ ### `lib/calc.rb`
67
+
68
+ Lines 100.00% (6 / 6) · Branches 50.00% (2 / 4)
69
+
70
+ Branches never taken: 7 `else`, 16 `else`.
71
+ ";
72
+ assert_eq!(String::from_utf8(out).unwrap(), expected);
73
+ }
74
+
75
+ #[test]
76
+ fn everything_ran() {
77
+ let dir = TempDir::new().unwrap();
78
+ let mut file = calc(&dir);
79
+ file.branches = None;
80
+ let files = [file];
81
+ let reports = reports(&files);
82
+ let mut out = Vec::new();
83
+ write_markdown(&summary(&reports, &[Criterion::Line]), &mut out).unwrap();
84
+ assert!(
85
+ String::from_utf8(out).unwrap().ends_with("by padded_cell 0.1.0.\n\nEverything ran.\n")
86
+ );
87
+ }
88
+
89
+ #[test]
90
+ fn ranges_collapse_runs() {
91
+ assert_eq!(ranges(&[3, 4, 5, 9, 11, 12]), "3-5, 9, 11-12");
92
+ assert_eq!(ranges(&[7]), "7");
93
+ }
@@ -0,0 +1,201 @@
1
+ use std::io::{self, Write};
2
+ use std::path::Path;
3
+
4
+ use super::stats::{Criterion, Stats};
5
+ use super::{Branch, FileReport, Line, MethodReport, Summary, chomp, floor2};
6
+
7
+ #[cfg(test)]
8
+ mod tests;
9
+
10
+ /// GitHub job summaries take up to 1 MiB: files past it are only counted.
11
+ const BUDGET: usize = 1_000_000;
12
+ /// Lines of missed code shown per file.
13
+ const EXCERPT: usize = 40;
14
+
15
+ /// Writes `summary` as Markdown.
16
+ ///
17
+ /// # Errors
18
+ ///
19
+ /// What writing to `out` returns.
20
+ pub fn write_markdown(summary: &Summary<'_>, out: &mut impl Write) -> io::Result<()> {
21
+ let mut head = Vec::new();
22
+ header(&mut head, summary)?;
23
+ out.write_all(&head)?;
24
+ let mut files: Vec<&FileReport<'_>> =
25
+ summary.files.iter().filter(|file| missed(file) > 0).collect();
26
+ if files.is_empty() {
27
+ return out.write_all(b"Everything ran.\n");
28
+ }
29
+ files.sort_by(|a, b| missed(b).cmp(&missed(a)).then_with(|| a.file.name.cmp(&b.file.name)));
30
+ out.write_all(b"## Missed, most first\n")?;
31
+ let mut written = head.len();
32
+ for (index, file) in files.iter().enumerate() {
33
+ let mut section = Vec::new();
34
+ file_section(&mut section, summary, file)?;
35
+ written = written.strict_add(section.len());
36
+ if written > BUDGET {
37
+ let rest = files.len().strict_sub(index);
38
+ return writeln!(out, "\n{rest} more files have missed code: see `lcov.info`.");
39
+ }
40
+ out.write_all(&section)?;
41
+ }
42
+ Ok(())
43
+ }
44
+
45
+ fn header(out: &mut Vec<u8>, summary: &Summary<'_>) -> io::Result<()> {
46
+ let meta = &summary.meta;
47
+ writeln!(out, "# Coverage: {}\n", meta.project_name)?;
48
+ totals(out, summary, |criterion| {
49
+ let stats = summary.totals.get(criterion);
50
+ (stats, stats.percent())
51
+ })?;
52
+ write!(out, "\nGenerated {} by {}", meta.timestamp, meta.generator)?;
53
+ if let Some(commit) = &meta.commit {
54
+ write!(out, ", at `{}`", commit.get(..12).unwrap_or(commit))?;
55
+ }
56
+ out.write_all(b".\n\n")?;
57
+ if summary.groups.is_empty() {
58
+ return Ok(());
59
+ }
60
+ out.write_all(b"| Group |")?;
61
+ for &criterion in &summary.measured {
62
+ write!(out, " {} |", label(criterion))?;
63
+ }
64
+ out.write_all(b"\n|---|")?;
65
+ out.write_all(&b"---:|".repeat(summary.measured.len()))?;
66
+ out.write_all(b"\n")?;
67
+ for (name, _, group) in &summary.groups {
68
+ write!(out, "| {name} |")?;
69
+ for &criterion in &summary.measured {
70
+ let stats = group.get(criterion);
71
+ write!(out, " {} |", ratio(&stats, stats.percent()))?;
72
+ }
73
+ out.write_all(b"\n")?;
74
+ }
75
+ out.write_all(b"\n")
76
+ }
77
+
78
+ fn file_section(out: &mut Vec<u8>, summary: &Summary<'_>, file: &FileReport<'_>) -> io::Result<()> {
79
+ write!(out, "\n### `{}`\n\n", file.file.name)?;
80
+ totals(out, summary, |criterion| file.stats.with_percent(criterion))?;
81
+ let lines: Vec<u32> = file.lines().filter(Line::missed).map(|line| line.number).collect();
82
+ if !lines.is_empty() {
83
+ writeln!(out, "\nLines never run: {}.", ranges(&lines))?;
84
+ }
85
+ // By line: `208 then, 306 then else`.
86
+ let mut branches: Vec<(u32, &str)> = file
87
+ .reported_branches()
88
+ .filter(Branch::missed)
89
+ .map(|branch| (branch.report_line(), branch.kind))
90
+ .collect();
91
+ branches.sort_by_key(|&(line, _)| line);
92
+ if !branches.is_empty() {
93
+ let mut lines: Vec<String> = Vec::new();
94
+ for chunk in branches.chunk_by(|a, b| a.0 == b.0) {
95
+ let kinds: Vec<&str> = chunk.iter().map(|&(_, kind)| kind).collect();
96
+ lines.push(format!("{} `{}`", chunk[0].0, kinds.join(" ")));
97
+ }
98
+ writeln!(out, "\nBranches never taken: {}.", lines.join(", "))?;
99
+ }
100
+ let methods: Vec<String> = file
101
+ .methods()
102
+ .filter(MethodReport::missed)
103
+ .map(|method| {
104
+ let method = method.method;
105
+ format!("`{}#{}` at {}", method.owner, method.name, method.span.start_line)
106
+ })
107
+ .collect();
108
+ if !methods.is_empty() {
109
+ writeln!(out, "\nMethods never called: {}.", methods.join(", "))?;
110
+ }
111
+ excerpt(out, file, &lines)
112
+ }
113
+
114
+ /// The missed lines, numbered, a `…` between runs.
115
+ fn excerpt(out: &mut Vec<u8>, file: &FileReport<'_>, missed: &[u32]) -> io::Result<()> {
116
+ if missed.is_empty() {
117
+ return Ok(());
118
+ }
119
+ let shown = &missed[..missed.len().min(EXCERPT)];
120
+ let texts: Vec<(u32, String)> = file
121
+ .lines()
122
+ .filter(|line| shown.binary_search(&line.number).is_ok())
123
+ .map(|line| (line.number, String::from_utf8_lossy(chomp(line.text)).into_owned()))
124
+ .collect();
125
+ // A fence longer than any run of backticks in the code.
126
+ let longest = texts.iter().map(|(_, text)| longest_run(text, '`')).max().unwrap_or(0);
127
+ let fence = "`".repeat(longest.max(2).strict_add(1));
128
+ let width = shown.last().map_or(1, |last| last.to_string().len());
129
+ let ruby = Path::new(&file.file.name).extension().is_some_and(|extension| extension == "rb");
130
+ let language = if ruby { "ruby" } else { "" };
131
+ write!(out, "\n{fence}{language}\n")?;
132
+ let mut previous = None;
133
+ for (number, text) in &texts {
134
+ if previous.is_some_and(|previous: u32| previous.strict_add(1) != *number) {
135
+ writeln!(out, "{:>width$}", "…")?;
136
+ }
137
+ writeln!(out, "{number:>width$} {text}")?;
138
+ previous = Some(*number);
139
+ }
140
+ if missed.len() > shown.len() {
141
+ writeln!(out, "… {} more lines", missed.len().strict_sub(shown.len()))?;
142
+ }
143
+ writeln!(out, "{fence}")
144
+ }
145
+
146
+ fn totals(
147
+ out: &mut Vec<u8>,
148
+ summary: &Summary<'_>,
149
+ stats: impl Fn(Criterion) -> (Stats, f64),
150
+ ) -> io::Result<()> {
151
+ let parts: Vec<String> = summary
152
+ .measured
153
+ .iter()
154
+ .map(|&criterion| {
155
+ let (stats, percent) = stats(criterion);
156
+ format!("{} {}", label(criterion), ratio(&stats, percent))
157
+ })
158
+ .collect();
159
+ writeln!(out, "{}", parts.join(" · "))
160
+ }
161
+
162
+ /// `83.83% (82782 / 98745)`
163
+ fn ratio(stats: &Stats, percent: f64) -> String {
164
+ format!("{:.2}% ({} / {})", floor2(percent), stats.covered, stats.total())
165
+ }
166
+
167
+ const fn label(criterion: Criterion) -> &'static str {
168
+ match criterion {
169
+ Criterion::Line => "Lines",
170
+ Criterion::Branch => "Branches",
171
+ Criterion::Method => "Methods",
172
+ }
173
+ }
174
+
175
+ /// What a file has left to cover.
176
+ const fn missed(file: &FileReport<'_>) -> u64 {
177
+ let counts = &file.stats.counts;
178
+ counts.line.missed.strict_add(counts.branch.missed).strict_add(counts.method.missed)
179
+ }
180
+
181
+ /// `[3, 4, 5, 9]` as `3-5, 9`.
182
+ fn ranges(numbers: &[u32]) -> String {
183
+ let mut runs: Vec<(u32, u32)> = Vec::new();
184
+ for &number in numbers {
185
+ match runs.last_mut() {
186
+ Some((_, end)) if end.strict_add(1) == number => *end = number,
187
+ _ => runs.push((number, number)),
188
+ }
189
+ }
190
+ let runs: Vec<String> = runs
191
+ .iter()
192
+ .map(
193
+ |&(start, end)| if start == end { start.to_string() } else { format!("{start}-{end}") },
194
+ )
195
+ .collect();
196
+ runs.join(", ")
197
+ }
198
+
199
+ fn longest_run(text: &str, character: char) -> usize {
200
+ text.split(|other| other != character).map(str::len).max().unwrap_or(0)
201
+ }
@@ -0,0 +1,38 @@
1
+ use super::*;
2
+
3
+ #[test]
4
+ fn defs_with_their_innermost_owner_and_span() {
5
+ let source = Source::new(
6
+ concat!(
7
+ "def top = 1\nmodule A\n class B::C\n é = 1; def x(y)\n y\n end\n",
8
+ " def self.z = 2\n end\nend\n",
9
+ )
10
+ .as_bytes()
11
+ .to_vec(),
12
+ );
13
+ let found: Vec<(String, String, Span)> = static_methods(&source)
14
+ .unwrap()
15
+ .into_iter()
16
+ .map(|method| (method.owner, method.name, method.span))
17
+ .collect();
18
+ let span = |start_line, start_column, end_line, end_column| Span {
19
+ start_line,
20
+ start_column,
21
+ end_line,
22
+ end_column,
23
+ };
24
+ assert_eq!(
25
+ found,
26
+ [
27
+ ("Object".to_owned(), "top".to_owned(), span(1, 0, 1, 11)),
28
+ // Columns count bytes, as Ruby's do: é is two.
29
+ ("B::C".to_owned(), "x".to_owned(), span(4, 10, 6, 5)),
30
+ ("B::C".to_owned(), "z".to_owned(), span(7, 4, 7, 18)),
31
+ ]
32
+ );
33
+ }
34
+
35
+ #[test]
36
+ fn sources_that_do_not_parse_have_no_methods() {
37
+ assert_eq!(static_methods(&Source::new(b"def broken(\n".to_vec())), None);
38
+ }
@@ -0,0 +1,62 @@
1
+ use ruby_prism::{ClassNode, DefNode, Location, ModuleNode, Visit};
2
+
3
+ use super::{Method, Source, Span};
4
+
5
+ #[cfg(test)]
6
+ mod tests;
7
+
8
+ /// `None` when the source does not parse.
9
+ #[must_use]
10
+ pub fn static_methods(source: &Source) -> Option<Vec<Method>> {
11
+ let result = ruby_prism::parse(source.bytes());
12
+ if result.errors().next().is_some() {
13
+ return None;
14
+ }
15
+ let mut collector = Collector { source, owners: Vec::new(), methods: Vec::new() };
16
+ collector.visit(&result.node());
17
+ Some(collector.methods)
18
+ }
19
+
20
+ struct Collector<'s> {
21
+ source: &'s Source,
22
+ owners: Vec<String>,
23
+ methods: Vec<Method>,
24
+ }
25
+
26
+ impl Collector<'_> {
27
+ fn within(&mut self, owner: &Location<'_>, visit: impl FnOnce(&mut Self)) {
28
+ self.owners.push(String::from_utf8_lossy(owner.as_slice()).into_owned());
29
+ visit(self);
30
+ self.owners.pop();
31
+ }
32
+
33
+ fn span(&self, location: &Location<'_>) -> Span {
34
+ let (start_line, start_column) = self.source.position(location.start_offset());
35
+ let (end_line, end_column) = self.source.position(location.end_offset());
36
+ Span { start_line, start_column, end_line, end_column }
37
+ }
38
+ }
39
+
40
+ impl<'pr> Visit<'pr> for Collector<'_> {
41
+ fn visit_class_node(&mut self, node: &ClassNode<'pr>) {
42
+ self.within(&node.constant_path().location(), |collector| {
43
+ ruby_prism::visit_class_node(collector, node);
44
+ });
45
+ }
46
+
47
+ fn visit_module_node(&mut self, node: &ModuleNode<'pr>) {
48
+ self.within(&node.constant_path().location(), |collector| {
49
+ ruby_prism::visit_module_node(collector, node);
50
+ });
51
+ }
52
+
53
+ fn visit_def_node(&mut self, node: &DefNode<'pr>) {
54
+ self.methods.push(Method {
55
+ owner: self.owners.last().map_or("Object", String::as_str).to_owned(),
56
+ name: String::from_utf8_lossy(node.name().as_slice()).into_owned(),
57
+ span: self.span(&node.location()),
58
+ hits: 0,
59
+ });
60
+ ruby_prism::visit_def_node(self, node);
61
+ }
62
+ }
@@ -0,0 +1,45 @@
1
+ use tempfile::TempDir;
2
+
3
+ use super::super::{Meta, Totals};
4
+ use super::*;
5
+
6
+ fn empty() -> Summary<'static> {
7
+ Summary {
8
+ files: &[],
9
+ measured: Vec::new(),
10
+ totals: Totals::default(),
11
+ groups: Vec::new(),
12
+ violations: Vec::new(),
13
+ meta: Meta::default(),
14
+ }
15
+ }
16
+
17
+ #[test]
18
+ fn formats_by_name() {
19
+ assert_eq!("lcov".parse(), Ok(Format::Lcov));
20
+ assert_eq!("json".parse(), Ok(Format::Json));
21
+ assert_eq!("html5".parse::<Format>(), Err(UnknownFormat("html5".to_owned())));
22
+ }
23
+
24
+ #[test]
25
+ fn writes_into_a_new_directory_without_leftovers() {
26
+ let dir = TempDir::new().unwrap();
27
+ let output = dir.path().join("coverage");
28
+ let written = write(&output, Format::Json, &empty()).unwrap();
29
+
30
+ assert_eq!(written, [output.join("coverage.json"), output.join(".resultset.json")]);
31
+ assert_eq!(
32
+ std::fs::read(output.join(".resultset.json")).unwrap(),
33
+ b"{\"padded_cell\":{\"coverage\":{},\"timestamp\":0.0}}\n"
34
+ );
35
+ assert_eq!(std::fs::read_dir(&output).unwrap().count(), 2);
36
+ }
37
+
38
+ #[test]
39
+ fn unwritable_directories_are_file_errors() {
40
+ let dir = TempDir::new().unwrap();
41
+ let blocker = dir.path().join("file");
42
+ std::fs::write(&blocker, "").unwrap();
43
+ let error = write(&blocker.join("coverage"), Format::Lcov, &empty()).unwrap_err();
44
+ assert_eq!(error.path, blocker.join("coverage/lcov.info"));
45
+ }
@@ -0,0 +1,103 @@
1
+ use std::fs::{self, File};
2
+ use std::io::{self, BufWriter, Write};
3
+ use std::path::{Path, PathBuf};
4
+ use std::str::FromStr;
5
+
6
+ use super::{
7
+ Summary, write_coverage_json, write_last_run, write_lcov, write_markdown, write_resultset,
8
+ };
9
+ use crate::FileError;
10
+
11
+ #[cfg(test)]
12
+ mod tests;
13
+
14
+ /// A report format.
15
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
16
+ pub enum Format {
17
+ /// `lcov.info`
18
+ Lcov,
19
+ /// `coverage.json` and `.resultset.json`.
20
+ Json,
21
+ /// `coverage.md`: totals, then the code that never ran.
22
+ Markdown,
23
+ }
24
+
25
+ impl Format {
26
+ /// The files the format writes, in the report directory.
27
+ #[must_use]
28
+ pub const fn file_names(self) -> &'static [&'static str] {
29
+ match self {
30
+ Self::Lcov => &["lcov.info"],
31
+ Self::Json => &["coverage.json", ".resultset.json"],
32
+ Self::Markdown => &["coverage.md"],
33
+ }
34
+ }
35
+ }
36
+
37
+ /// A format name nothing writes.
38
+ #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
39
+ #[error("unknown coverage format {0:?} (known: markdown, lcov, json)")]
40
+ pub struct UnknownFormat(pub String);
41
+
42
+ impl FromStr for Format {
43
+ type Err = UnknownFormat;
44
+
45
+ fn from_str(name: &str) -> Result<Self, UnknownFormat> {
46
+ match name {
47
+ "lcov" => Ok(Self::Lcov),
48
+ "json" => Ok(Self::Json),
49
+ "markdown" => Ok(Self::Markdown),
50
+ _ => Err(UnknownFormat(name.to_owned())),
51
+ }
52
+ }
53
+ }
54
+
55
+ /// Writes `summary` in `format` into `dir`, which it creates; returns the
56
+ /// paths written.
57
+ ///
58
+ /// # Errors
59
+ ///
60
+ /// [`FileError`] for the file that could not be written.
61
+ pub fn write(dir: &Path, format: Format, summary: &Summary<'_>) -> Result<Vec<PathBuf>, FileError> {
62
+ format
63
+ .file_names()
64
+ .iter()
65
+ .map(|&name| {
66
+ write_file(dir, name, |out| match name {
67
+ "lcov.info" => write_lcov(summary.files, out),
68
+ "coverage.json" => write_coverage_json(summary, out),
69
+ "coverage.md" => write_markdown(summary, out),
70
+ _ => write_resultset(summary, out),
71
+ })
72
+ })
73
+ .collect()
74
+ }
75
+
76
+ /// Writes `.last_run.json`, which the next run's `maximum_drop` reads.
77
+ ///
78
+ /// # Errors
79
+ ///
80
+ /// [`FileError`] when it could not be written.
81
+ pub fn write_last_run_file(dir: &Path, summary: &Summary<'_>) -> Result<PathBuf, FileError> {
82
+ write_file(dir, ".last_run.json", |out| write_last_run(summary, out))
83
+ }
84
+
85
+ /// Replaces `dir/name` at once: a reader never sees half a report.
86
+ pub(super) fn write_file(
87
+ dir: &Path,
88
+ name: &str,
89
+ render: impl FnOnce(&mut BufWriter<File>) -> io::Result<()>,
90
+ ) -> Result<PathBuf, FileError> {
91
+ let path = dir.join(name);
92
+ let partial = dir.join(format!(".{name}.partial"));
93
+ let error = |source| FileError { path: path.clone(), source };
94
+ fs::create_dir_all(dir).map_err(error)?;
95
+ let written = File::create(&partial).and_then(|file| {
96
+ let mut out = BufWriter::new(file);
97
+ render(&mut out)?;
98
+ out.flush()?;
99
+ out.into_inner().map_err(io::IntoInnerError::into_error)?.sync_all()
100
+ });
101
+ written.and_then(|()| fs::rename(&partial, &path)).map_err(error)?;
102
+ Ok(path)
103
+ }
@@ -0,0 +1,96 @@
1
+ use tempfile::TempDir;
2
+
3
+ use super::super::FileCoverage;
4
+ use super::super::test_support::{calc, reports, summary};
5
+ use super::*;
6
+
7
+ fn render(file: &FileCoverage, measured: &[Criterion], write: WriteFn) -> String {
8
+ let files = [file.clone()];
9
+ let reports = reports(&files);
10
+ let mut summary = summary(&reports, measured);
11
+ summary.groups = vec![("Core".to_owned(), vec![0], summary.totals)];
12
+ summary.violations = vec![Violation {
13
+ scope: Scope::File("lib/calc.rb".to_owned()),
14
+ criterion: Criterion::Branch,
15
+ limit: 60.0,
16
+ actual: 50.0,
17
+ }];
18
+ summary.meta = Meta {
19
+ root: "/app".to_owned(),
20
+ timestamp: "2026-10-11T13:00:00.000+02:00".to_owned(),
21
+ created_at: 1_791_716_400.5,
22
+ commit: None,
23
+ project_name: "App".to_owned(),
24
+ generator: "padded_cell 0.1.0".to_owned(),
25
+ };
26
+ let mut out = Vec::new();
27
+ write(&summary, &mut out).unwrap();
28
+ String::from_utf8(out).unwrap()
29
+ }
30
+
31
+ type WriteFn = fn(&Summary<'_>, &mut Vec<u8>) -> io::Result<()>;
32
+
33
+ #[test]
34
+ fn last_run_holds_the_floored_percents_measured() {
35
+ let dir = TempDir::new().unwrap();
36
+ let rendered = render(&calc(&dir), &[Criterion::Line, Criterion::Branch], write_last_run);
37
+ assert_eq!(
38
+ rendered,
39
+ "{\n \"result\": {\n \"line\": 100.0,\n \"branch\": 50.0\n }\n}\n"
40
+ );
41
+ }
42
+
43
+ /// Keys as Ruby's `Array#to_s` writes them; ids numbered in compile order.
44
+ #[test]
45
+ fn resultset_keys_are_rubys() {
46
+ let dir = TempDir::new().unwrap();
47
+ let measured = [Criterion::Line, Criterion::Branch, Criterion::Method];
48
+ let rendered = render(&calc(&dir), &measured, write_resultset);
49
+ for expected in [
50
+ r#"{"padded_cell":{"coverage":{""#,
51
+ r#"/calc.rb":{"lines":[null,null,1,1,2,1,null,1,null,null,null,null,1,null,null,1,null],"#,
52
+ concat!(
53
+ r#""branches":{"[:if, 0, 5, 4, 9, 7]":"#,
54
+ r#"{"[:then, 1, 6, 6, 6, 15]":1,"[:else, 2, 8, 6, 8, 12]":0},"#,
55
+ ),
56
+ r#""[:if, 3, 16, 20, 16, 37]":{"[:then, 4, 16, 27, 16, 31]":1,"#,
57
+ r#""methods":{"[Calc, :sign, 4, 2, 10, 5]":2,"#,
58
+ r#""timestamp":1791716400.5}}"#,
59
+ ] {
60
+ assert!(rendered.contains(expected), "{expected} in {rendered}");
61
+ }
62
+ assert_eq!(ruby_symbol("&."), r#":"&.""#);
63
+ assert_eq!(ruby_symbol("empty?"), ":empty?");
64
+ }
65
+
66
+ #[test]
67
+ fn coverage_json_sections() {
68
+ let dir = TempDir::new().unwrap();
69
+ let rendered = render(&calc(&dir), &[Criterion::Line, Criterion::Branch], |s, o| {
70
+ write_coverage_json(s, o)
71
+ });
72
+ for expected in [
73
+ &format!(" \"$schema\": \"{SCHEMA}\",\n"),
74
+ " \"simplecov_version\": \"padded_cell 0.1.0\",\n",
75
+ " \"commit\": null,\n",
76
+ " \"method_coverage\": false\n",
77
+ " \"source\": [\n \"# frozen_string_literal: true\",\n",
78
+ " \"ignored\",\n",
79
+ concat!(
80
+ " \"type\": \"else\",\n \"start_line\": 8,\n",
81
+ " \"end_line\": 8,\n \"coverage\": 0,\n",
82
+ " \"inline\": false,\n \"report_line\": 7\n",
83
+ ),
84
+ " \"branches_covered_percent\": 50.0,\n",
85
+ " \"Core\": {\n \"lines\": {\n",
86
+ " \"files\": [\n \"lib/calc.rb\"\n ]\n",
87
+ concat!(
88
+ " \"errors\": {\n \"minimum_coverage_by_file\": {\n",
89
+ " \"lib/calc.rb\": {\n \"branches\": {\n",
90
+ " \"expected\": 60.0,\n \"actual\": 50.0\n",
91
+ ),
92
+ ] {
93
+ assert!(rendered.contains(expected), "{expected:?} in {rendered}");
94
+ }
95
+ assert!(!rendered.contains("\"methods\""), "methods were not measured");
96
+ }