···19# and can be added to the global gitignore or merged into this file. For a more nuclear
20# option (not recommended) you can uncomment the following to ignore the entire idea folder.
21#.idea/
00
···19# and can be added to the global gitignore or merged into this file. For a more nuclear
20# option (not recommended) you can uncomment the following to ignore the entire idea folder.
21#.idea/
22+.sandbox/
23+lcov.info
+18
CHANGELOG.md
···000000000000000000
···1+# Changelog
2+3+All notable changes to this project will be documented in this file.
4+5+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+8+## [0.1.0] - 2026-01-13
9+10+### Added
11+12+- LOC command extension: file ranking and directory aggregation.
13+- Syntax highlighting using `syntect`.
14+- Configuration saving functionality.
15+- Core utilities and commands.
16+- Clone detector implementation (Rabin-Karp).
17+- Cyclomatic complexity and LOC metrics.
18+- mdbook Documentation with example/sample files.
···1+use crate::coverage::{CoverageReport, FileCoverage};
2+use owo_colors::OwoColorize;
3+4+#[cfg(test)]
5+fn strip_ansi_codes(s: &str) -> String {
6+ let mut result = String::new();
7+ let mut chars = s.chars().peekable();
8+9+ while let Some(c) = chars.next() {
10+ if c == '\x1b' {
11+ if chars.peek() == Some(&'[') {
12+ chars.next();
13+ while let Some(&c) = chars.peek() {
14+ chars.next();
15+ if c.is_ascii_alphabetic() {
16+ break;
17+ }
18+ }
19+ }
20+ } else {
21+ result.push(c);
22+ }
23+ }
24+25+ result
26+}
27+28+fn wrap_line_ranges(ranges: &[(u32, u32)], max_width: usize) -> String {
29+ let ranges_plain: Vec<String> = ranges
30+ .iter()
31+ .map(
32+ |(start, end)| {
33+ if start == end { start.to_string() } else { format!("{}-{}", start, end) }
34+ },
35+ )
36+ .collect();
37+38+ let full_str = ranges_plain.join(", ");
39+40+ if full_str.len() <= max_width {
41+ return full_str.red().to_string();
42+ }
43+44+ let mut result = String::new();
45+ let mut current_line = String::new();
46+ let mut current_width = 0;
47+48+ for (i, range_str) in ranges_plain.iter().enumerate() {
49+ let range_width = range_str.len();
50+ let comma_sep = ", ";
51+ let sep_width = if current_width > 0 { comma_sep.len() } else { 0 };
52+53+ if current_width + sep_width + range_width <= max_width {
54+ if current_width > 0 {
55+ current_line.push_str(comma_sep);
56+ current_width += comma_sep.len();
57+ }
58+ current_line.push_str(range_str);
59+ current_width += range_width;
60+ } else {
61+ if !current_line.is_empty() {
62+ result.push_str(¤t_line.red().to_string());
63+ result.push('\n');
64+ }
65+ current_line = range_str.to_string();
66+ current_width = range_width;
67+ }
68+69+ if i == ranges_plain.len() - 1 && !current_line.is_empty() {
70+ result.push_str(¤t_line.red().to_string());
71+ }
72+ }
73+74+ result
75+}
76+77+pub fn report_coverage(report: &CoverageReport) -> String {
78+ let mut output = String::new();
79+80+ output.push_str(&"=".repeat(80).cyan().to_string());
81+ output.push('\n');
82+ output.push_str(&"COVERAGE REPORT".cyan().bold().to_string());
83+ output.push('\n');
84+ output.push_str(&"=".repeat(80).cyan().to_string());
85+ output.push_str("\n\n");
86+87+ output.push_str(&"SUMMARY".green().bold().to_string());
88+ output.push('\n');
89+ output.push_str(&"-".repeat(80).cyan().to_string());
90+ output.push('\n');
91+ output.push_str(&format!("Total files: {}\n", report.files.len().bold()));
92+ output.push_str(&format!("Total lines: {}\n", report.totals.total.bold()));
93+ output.push_str(&format!(
94+ "Covered lines: {}\n",
95+ report.totals.hit.green().bold()
96+ ));
97+ output.push_str(&format!(
98+ "Uncovered lines: {}\n",
99+ report.totals.miss.red().bold()
100+ ));
101+102+ let rate_text = if report.totals.rate >= 80.0 {
103+ format!("{:.2}%", report.totals.rate).green().bold().to_string()
104+ } else if report.totals.rate >= 50.0 {
105+ format!("{:.2}%", report.totals.rate).yellow().bold().to_string()
106+ } else {
107+ format!("{:.2}%", report.totals.rate).red().bold().to_string()
108+ };
109+ output.push_str(&format!("Coverage rate: {}\n\n", rate_text));
110+111+ if !report.files.is_empty() {
112+ output.push_str(&"FILE COVERAGE".green().bold().to_string());
113+ output.push('\n');
114+ output.push_str(&"-".repeat(80).cyan().to_string());
115+ output.push('\n');
116+117+ for file in &report.files {
118+ output.push_str(&format!("{}\n", format_file_coverage(file, 2)));
119+ }
120+ }
121+122+ output.push_str(&"=".repeat(80).cyan().to_string());
123+ output.push('\n');
124+125+ output
126+}
127+128+pub fn format_file_coverage(file: &FileCoverage, indent: usize) -> String {
129+ let spaces = " ".repeat(indent);
130+ let uncovered_prefix = format!("{} Uncovered: ", spaces);
131+ let mut output = String::new();
132+133+ output.push_str(&spaces);
134+ output.push_str("FILE: ");
135+ let file_path = file.path.bold().to_string();
136+ let current_width = spaces.len() + "FILE: ".len();
137+ let remaining_width = 80 - current_width;
138+139+ let path_only = file.path.to_string();
140+ if path_only.len() <= remaining_width {
141+ output.push_str(&file_path);
142+ } else {
143+ for (i, chunk) in path_only.as_bytes().chunks(remaining_width).enumerate() {
144+ if i > 0 {
145+ output.push_str(&format!("\n{} ", spaces));
146+ }
147+ output.push_str(&String::from_utf8_lossy(chunk).bold().to_string());
148+ }
149+ }
150+ output.push('\n');
151+152+ let rate_text = if file.summary.rate >= 80.0 {
153+ format!("{:.2}%", file.summary.rate).green().bold().to_string()
154+ } else if file.summary.rate >= 50.0 {
155+ format!("{:.2}%", file.summary.rate).yellow().bold().to_string()
156+ } else {
157+ format!("{:.2}%", file.summary.rate).red().bold().to_string()
158+ };
159+160+ output.push_str(&spaces);
161+ output.push_str(&format!(
162+ " Lines: {} / {} ({})\n",
163+ file.summary.hit.green().bold(),
164+ file.summary.total,
165+ rate_text
166+ ));
167+168+ if !file.miss_ranges.is_empty() {
169+ output.push_str(&uncovered_prefix);
170+ let max_width = 80 - uncovered_prefix.len();
171+172+ let wrapped_ranges = wrap_line_ranges(&file.miss_ranges, max_width);
173+ for (i, line) in wrapped_ranges.lines().enumerate() {
174+ if i > 0 {
175+ output.push_str(&" ".repeat(uncovered_prefix.len()));
176+ }
177+ output.push_str(line);
178+ output.push('\n');
179+ }
180+ }
181+182+ output
183+}
184+185+#[cfg(test)]
186+mod tests {
187+ use super::*;
188+ use crate::coverage::FileCoverage;
189+ use std::collections::BTreeMap;
190+191+ #[test]
192+ fn test_report_coverage_empty() {
193+ let report = CoverageReport::new(vec![]);
194+ let output = report_coverage(&report);
195+ let output = strip_ansi_codes(&output);
196+197+ assert!(output.contains("COVERAGE REPORT"));
198+ assert!(output.contains("Total files: 0"));
199+ }
200+201+ #[test]
202+ fn test_report_coverage_with_files() {
203+ let mut lines = BTreeMap::new();
204+ lines.insert(1, 10);
205+ lines.insert(2, 0);
206+ lines.insert(3, 5);
207+208+ let file = FileCoverage::new("test.rs".to_string(), lines);
209+ let report = CoverageReport::new(vec![file]);
210+ let output = report_coverage(&report);
211+ let output = strip_ansi_codes(&output);
212+213+ assert!(output.contains("COVERAGE REPORT"));
214+ assert!(output.contains("test.rs"));
215+ assert!(output.contains("2 / 3"));
216+ assert!(output.contains("Uncovered: 2"));
217+ }
218+219+ #[test]
220+ fn test_format_file_coverage() {
221+ let mut lines = BTreeMap::new();
222+ lines.insert(1, 10);
223+ lines.insert(2, 0);
224+ lines.insert(3, 5);
225+ lines.insert(4, 0);
226+ lines.insert(5, 0);
227+228+ let file = FileCoverage::new("test.rs".to_string(), lines);
229+ let output = format_file_coverage(&file, 2);
230+ let output = strip_ansi_codes(&output);
231+232+ assert!(output.contains("FILE: test.rs"));
233+ assert!(output.contains("2 / 5"));
234+ assert!(output.contains("Uncovered: 2, 4-5"));
235+ }
236+237+ #[test]
238+ fn test_format_file_coverage_full() {
239+ let mut lines = BTreeMap::new();
240+ lines.insert(1, 10);
241+ lines.insert(2, 5);
242+ lines.insert(3, 1);
243+244+ let file = FileCoverage::new("full.rs".to_string(), lines);
245+ let output = format_file_coverage(&file, 2);
246+ let output = strip_ansi_codes(&output);
247+248+ assert!(output.contains("FILE: full.rs"));
249+ assert!(output.contains("3 / 3 (100.00%)"));
250+ assert!(!output.contains("Uncovered"));
251+ }
252+}
+7
crates/core/src/reporter/mod.rs
···0000000
···1+pub mod coverage_jsonl;
2+pub mod coverage_term;
3+pub mod legacy;
4+5+pub use coverage_jsonl::JsonlReporter;
6+pub use coverage_term::{format_file_coverage, report_coverage};
7+pub use legacy::{FileReport, Report, Summary};