···1919# and can be added to the global gitignore or merged into this file. For a more nuclear
2020# option (not recommended) you can uncomment the following to ignore the entire idea folder.
2121#.idea/
2222+.sandbox/
2323+lcov.info
+18
CHANGELOG.md
···11+# Changelog
22+33+All notable changes to this project will be documented in this file.
44+55+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77+88+## [0.1.0] - 2026-01-13
99+1010+### Added
1111+1212+- LOC command extension: file ranking and directory aggregation.
1313+- Syntax highlighting using `syntect`.
1414+- Configuration saving functionality.
1515+- Core utilities and commands.
1616+- Clone detector implementation (Rabin-Karp).
1717+- Cyclomatic complexity and LOC metrics.
1818+- mdbook Documentation with example/sample files.
···11+use crate::coverage::{CoverageReport, FileCoverage};
22+use owo_colors::OwoColorize;
33+44+#[cfg(test)]
55+fn strip_ansi_codes(s: &str) -> String {
66+ let mut result = String::new();
77+ let mut chars = s.chars().peekable();
88+99+ while let Some(c) = chars.next() {
1010+ if c == '\x1b' {
1111+ if chars.peek() == Some(&'[') {
1212+ chars.next();
1313+ while let Some(&c) = chars.peek() {
1414+ chars.next();
1515+ if c.is_ascii_alphabetic() {
1616+ break;
1717+ }
1818+ }
1919+ }
2020+ } else {
2121+ result.push(c);
2222+ }
2323+ }
2424+2525+ result
2626+}
2727+2828+fn wrap_line_ranges(ranges: &[(u32, u32)], max_width: usize) -> String {
2929+ let ranges_plain: Vec<String> = ranges
3030+ .iter()
3131+ .map(
3232+ |(start, end)| {
3333+ if start == end { start.to_string() } else { format!("{}-{}", start, end) }
3434+ },
3535+ )
3636+ .collect();
3737+3838+ let full_str = ranges_plain.join(", ");
3939+4040+ if full_str.len() <= max_width {
4141+ return full_str.red().to_string();
4242+ }
4343+4444+ let mut result = String::new();
4545+ let mut current_line = String::new();
4646+ let mut current_width = 0;
4747+4848+ for (i, range_str) in ranges_plain.iter().enumerate() {
4949+ let range_width = range_str.len();
5050+ let comma_sep = ", ";
5151+ let sep_width = if current_width > 0 { comma_sep.len() } else { 0 };
5252+5353+ if current_width + sep_width + range_width <= max_width {
5454+ if current_width > 0 {
5555+ current_line.push_str(comma_sep);
5656+ current_width += comma_sep.len();
5757+ }
5858+ current_line.push_str(range_str);
5959+ current_width += range_width;
6060+ } else {
6161+ if !current_line.is_empty() {
6262+ result.push_str(¤t_line.red().to_string());
6363+ result.push('\n');
6464+ }
6565+ current_line = range_str.to_string();
6666+ current_width = range_width;
6767+ }
6868+6969+ if i == ranges_plain.len() - 1 && !current_line.is_empty() {
7070+ result.push_str(¤t_line.red().to_string());
7171+ }
7272+ }
7373+7474+ result
7575+}
7676+7777+pub fn report_coverage(report: &CoverageReport) -> String {
7878+ let mut output = String::new();
7979+8080+ output.push_str(&"=".repeat(80).cyan().to_string());
8181+ output.push('\n');
8282+ output.push_str(&"COVERAGE REPORT".cyan().bold().to_string());
8383+ output.push('\n');
8484+ output.push_str(&"=".repeat(80).cyan().to_string());
8585+ output.push_str("\n\n");
8686+8787+ output.push_str(&"SUMMARY".green().bold().to_string());
8888+ output.push('\n');
8989+ output.push_str(&"-".repeat(80).cyan().to_string());
9090+ output.push('\n');
9191+ output.push_str(&format!("Total files: {}\n", report.files.len().bold()));
9292+ output.push_str(&format!("Total lines: {}\n", report.totals.total.bold()));
9393+ output.push_str(&format!(
9494+ "Covered lines: {}\n",
9595+ report.totals.hit.green().bold()
9696+ ));
9797+ output.push_str(&format!(
9898+ "Uncovered lines: {}\n",
9999+ report.totals.miss.red().bold()
100100+ ));
101101+102102+ let rate_text = if report.totals.rate >= 80.0 {
103103+ format!("{:.2}%", report.totals.rate).green().bold().to_string()
104104+ } else if report.totals.rate >= 50.0 {
105105+ format!("{:.2}%", report.totals.rate).yellow().bold().to_string()
106106+ } else {
107107+ format!("{:.2}%", report.totals.rate).red().bold().to_string()
108108+ };
109109+ output.push_str(&format!("Coverage rate: {}\n\n", rate_text));
110110+111111+ if !report.files.is_empty() {
112112+ output.push_str(&"FILE COVERAGE".green().bold().to_string());
113113+ output.push('\n');
114114+ output.push_str(&"-".repeat(80).cyan().to_string());
115115+ output.push('\n');
116116+117117+ for file in &report.files {
118118+ output.push_str(&format!("{}\n", format_file_coverage(file, 2)));
119119+ }
120120+ }
121121+122122+ output.push_str(&"=".repeat(80).cyan().to_string());
123123+ output.push('\n');
124124+125125+ output
126126+}
127127+128128+pub fn format_file_coverage(file: &FileCoverage, indent: usize) -> String {
129129+ let spaces = " ".repeat(indent);
130130+ let uncovered_prefix = format!("{} Uncovered: ", spaces);
131131+ let mut output = String::new();
132132+133133+ output.push_str(&spaces);
134134+ output.push_str("FILE: ");
135135+ let file_path = file.path.bold().to_string();
136136+ let current_width = spaces.len() + "FILE: ".len();
137137+ let remaining_width = 80 - current_width;
138138+139139+ let path_only = file.path.to_string();
140140+ if path_only.len() <= remaining_width {
141141+ output.push_str(&file_path);
142142+ } else {
143143+ for (i, chunk) in path_only.as_bytes().chunks(remaining_width).enumerate() {
144144+ if i > 0 {
145145+ output.push_str(&format!("\n{} ", spaces));
146146+ }
147147+ output.push_str(&String::from_utf8_lossy(chunk).bold().to_string());
148148+ }
149149+ }
150150+ output.push('\n');
151151+152152+ let rate_text = if file.summary.rate >= 80.0 {
153153+ format!("{:.2}%", file.summary.rate).green().bold().to_string()
154154+ } else if file.summary.rate >= 50.0 {
155155+ format!("{:.2}%", file.summary.rate).yellow().bold().to_string()
156156+ } else {
157157+ format!("{:.2}%", file.summary.rate).red().bold().to_string()
158158+ };
159159+160160+ output.push_str(&spaces);
161161+ output.push_str(&format!(
162162+ " Lines: {} / {} ({})\n",
163163+ file.summary.hit.green().bold(),
164164+ file.summary.total,
165165+ rate_text
166166+ ));
167167+168168+ if !file.miss_ranges.is_empty() {
169169+ output.push_str(&uncovered_prefix);
170170+ let max_width = 80 - uncovered_prefix.len();
171171+172172+ let wrapped_ranges = wrap_line_ranges(&file.miss_ranges, max_width);
173173+ for (i, line) in wrapped_ranges.lines().enumerate() {
174174+ if i > 0 {
175175+ output.push_str(&" ".repeat(uncovered_prefix.len()));
176176+ }
177177+ output.push_str(line);
178178+ output.push('\n');
179179+ }
180180+ }
181181+182182+ output
183183+}
184184+185185+#[cfg(test)]
186186+mod tests {
187187+ use super::*;
188188+ use crate::coverage::FileCoverage;
189189+ use std::collections::BTreeMap;
190190+191191+ #[test]
192192+ fn test_report_coverage_empty() {
193193+ let report = CoverageReport::new(vec![]);
194194+ let output = report_coverage(&report);
195195+ let output = strip_ansi_codes(&output);
196196+197197+ assert!(output.contains("COVERAGE REPORT"));
198198+ assert!(output.contains("Total files: 0"));
199199+ }
200200+201201+ #[test]
202202+ fn test_report_coverage_with_files() {
203203+ let mut lines = BTreeMap::new();
204204+ lines.insert(1, 10);
205205+ lines.insert(2, 0);
206206+ lines.insert(3, 5);
207207+208208+ let file = FileCoverage::new("test.rs".to_string(), lines);
209209+ let report = CoverageReport::new(vec![file]);
210210+ let output = report_coverage(&report);
211211+ let output = strip_ansi_codes(&output);
212212+213213+ assert!(output.contains("COVERAGE REPORT"));
214214+ assert!(output.contains("test.rs"));
215215+ assert!(output.contains("2 / 3"));
216216+ assert!(output.contains("Uncovered: 2"));
217217+ }
218218+219219+ #[test]
220220+ fn test_format_file_coverage() {
221221+ let mut lines = BTreeMap::new();
222222+ lines.insert(1, 10);
223223+ lines.insert(2, 0);
224224+ lines.insert(3, 5);
225225+ lines.insert(4, 0);
226226+ lines.insert(5, 0);
227227+228228+ let file = FileCoverage::new("test.rs".to_string(), lines);
229229+ let output = format_file_coverage(&file, 2);
230230+ let output = strip_ansi_codes(&output);
231231+232232+ assert!(output.contains("FILE: test.rs"));
233233+ assert!(output.contains("2 / 5"));
234234+ assert!(output.contains("Uncovered: 2, 4-5"));
235235+ }
236236+237237+ #[test]
238238+ fn test_format_file_coverage_full() {
239239+ let mut lines = BTreeMap::new();
240240+ lines.insert(1, 10);
241241+ lines.insert(2, 5);
242242+ lines.insert(3, 1);
243243+244244+ let file = FileCoverage::new("full.rs".to_string(), lines);
245245+ let output = format_file_coverage(&file, 2);
246246+ let output = strip_ansi_codes(&output);
247247+248248+ assert!(output.contains("FILE: full.rs"));
249249+ assert!(output.contains("3 / 3 (100.00%)"));
250250+ assert!(!output.contains("Uncovered"));
251251+ }
252252+}
+7
crates/core/src/reporter/mod.rs
···11+pub mod coverage_jsonl;
22+pub mod coverage_term;
33+pub mod legacy;
44+55+pub use coverage_jsonl::JsonlReporter;
66+pub use coverage_term::{format_file_coverage, report_coverage};
77+pub use legacy::{FileReport, Report, Summary};
+61
todo.txt
···11+================================================================================
22+todo.txt
33+================================================================================
44+A. Test Coverage Ingestion
55+ - Ingest LCOV/JSON from `cargo llvm-cov` or `cargo test -- --coverage`.
66+ - Render to terminal, HTML, Markdown, JSONL.
77+ - Support path normalization (`--root`, `--strip-prefix`, `--ignore-regex`).
88+ - Support Git diff coverage
99+1010+B. AST-Based Clone Detection
1111+ - Compare abstract syntax trees or subtrees.
1212+ - Identifies clones resilient to renamed variables or formatting changes.
1313+ - Requires per-language AST adapters.
1414+1515+C. Cognitive Complexity
1616+ - Scores human comprehension cost.
1717+ - Rewards flattened control flow, penalizes deep nesting.
1818+ - Requires AST-level traversal.
1919+2020+D. Maintainability Index
2121+ - Combines Cyclomatic, Halstead, and LOC into a single number.
2222+ - Good for dashboards and longitudinal tracking.
2323+2424+E. Semantic Clone Detection
2525+ - Goes beyond syntax: identifies logically equivalent code.
2626+ - Requires control/data-flow analysis.
2727+2828+F. Dependency Metrics
2929+ - Coupling, fan-in/fan-out, depth of inheritance.
3030+ - Requires language-specific type-resolution or symbol graph extraction.
3131+3232+G. Hotspot Analysis
3333+ - Combine Git history + complexity metrics.
3434+ - Identify files that change often AND are complex.
3535+3636+H. Incremental Mode
3737+ - Cache hashes/graphs.
3838+ - Analyze only changed files and touched boundaries.
3939+4040+I. Rich Reports
4141+ - HTML dashboards
4242+ - SVG graphs (CFG visualization)
4343+ - JSON with stable schema for CI systems
4444+4545+-------------------------------------------------------------------------------
4646+SUMMARY
4747+4848+MVP:
4949+ - LOC
5050+ - Cyclomatic Complexity
5151+ - Rabin-Karp Clone Detection
5252+ - Halstead Complexity
5353+5454+Beyond MVP:
5555+ - AST-based clones
5656+ - Cognitive Complexity
5757+ - Maintainability Index
5858+ - Coupling metrics
5959+ - Call graphs and data-flow graphs
6060+ - Hotspot analysis
6161+ - Incremental scanning