Skip to main content

cpd_core/
summary.rs

1// summary.rs — opt-in codebase summary: per-file metrics, folder rollup, top-N lists.
2//
3// Everything in this module runs only when `--summary` is enabled, after
4// detection has finished, over data already held in memory (SourceFile tokens
5// and detected clones). Nothing in the detection hot path calls into it.
6
7use crate::models::{CpdClone, SourceFile};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Metric used to rank files and folders in the summary.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "lowercase")]
14pub enum SummaryMetric {
15    #[default]
16    Tokens,
17    Lines,
18    Size,
19    Complexity,
20}
21
22impl std::str::FromStr for SummaryMetric {
23    type Err = String;
24
25    fn from_str(s: &str) -> Result<Self, Self::Err> {
26        match s {
27            "tokens" => Ok(Self::Tokens),
28            "lines" => Ok(Self::Lines),
29            "size" => Ok(Self::Size),
30            "complexity" => Ok(Self::Complexity),
31            other => Err(format!(
32                "invalid summary metric '{other}': must be one of: tokens, lines, size, complexity"
33            )),
34        }
35    }
36}
37
38impl std::fmt::Display for SummaryMetric {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        let s = match self {
41            Self::Tokens => "tokens",
42            Self::Lines => "lines",
43            Self::Size => "size",
44            Self::Complexity => "complexity",
45        };
46        f.write_str(s)
47    }
48}
49
50/// Per-file summary row.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct FileSummary {
54    pub path: String,
55    pub format: String,
56    pub lines: u64,
57    pub tokens: u64,
58    pub bytes: u64,
59    pub duplicated_lines: u64,
60    pub duplicated_tokens: u64,
61    /// Cyclomatic-complexity estimate: 1 + count of decision-point tokens
62    /// (`if`, `for`, `while`, `case`, `catch`, `&&`, `||`, `?`, …).
63    pub complexity: u64,
64}
65
66/// Per-folder rollup. Files are counted in their direct parent directory only
67/// (no cumulative ancestor totals), so every file contributes to exactly one
68/// folder row and rows are directly comparable.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71pub struct FolderSummary {
72    pub path: String,
73    pub files: u64,
74    pub lines: u64,
75    pub tokens: u64,
76    pub bytes: u64,
77    pub duplicated_lines: u64,
78    /// Sum of per-file complexity estimates (divide by `files` for the mean).
79    pub complexity: u64,
80}
81
82/// Codebase summary: top files and folder rollup.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct Summary {
86    /// Primary sort metric.
87    pub by: SummaryMetric,
88    /// Top-N files by `by`, descending. Every row carries all metrics
89    /// (tokens, lines, bytes, complexity, duplication) so one list serves
90    /// every lens; re-run with a different `--summary-by` to re-rank.
91    pub files: Vec<FileSummary>,
92    /// Top-N folders by `by`, direct-parent aggregation.
93    pub folders: Vec<FolderSummary>,
94    /// Total number of files analyzed (before top-N truncation).
95    pub total_files: u64,
96    /// Total number of folders (before top-N truncation).
97    pub total_folders: u64,
98}
99
100/// Decision-point tokens counted by the complexity estimate. Conservative,
101/// language-agnostic list: branch/loop keywords and short-circuit operators
102/// that appear as standalone tokens across supported languages.
103///
104/// Matching is ASCII-case-insensitive so case-insensitive and
105/// uppercase-keyword languages (SQL, PL/SQL, Fortran, COBOL, BASIC, Pascal)
106/// count too. The occasional identifier spelled like a keyword slightly
107/// inflates an estimate that is only used for ranking.
108fn is_decision_token(value: &str) -> bool {
109    let bytes = value.as_bytes();
110    if bytes.is_empty() || bytes.len() > 7 {
111        return false;
112    }
113    let mut lower = [0u8; 7];
114    for (dst, b) in lower.iter_mut().zip(bytes) {
115        *dst = b.to_ascii_lowercase();
116    }
117    matches!(
118        &lower[..bytes.len()],
119        b"if"
120            | b"elif"
121            | b"elsif"
122            | b"elseif"
123            | b"unless"
124            | b"for"
125            | b"foreach"
126            | b"while"
127            | b"until"
128            | b"case"
129            | b"cond"
130            | b"when"
131            | b"catch"
132            | b"rescue"
133            | b"except"
134            | b"andalso"
135            | b"orelse"
136            | b"&&"
137            | b"||"
138            | b"and"
139            | b"or"
140            | b"?"
141            | b"??"
142    )
143}
144
145/// A synthetic source is the per-sub-format shadow of a multi-format file
146/// (markdown/vue/svelte embedded code); its id is `<parent-id>:<format>` and
147/// its metrics are already covered by the parent entry.
148fn is_synthetic(source: &SourceFile) -> bool {
149    source
150        .id
151        .strip_suffix(source.format.as_str())
152        .is_some_and(|prefix| prefix.ends_with(':'))
153}
154
155fn metric_of(file: &FileSummary, by: SummaryMetric) -> u64 {
156    match by {
157        SummaryMetric::Tokens => file.tokens,
158        SummaryMetric::Lines => file.lines,
159        SummaryMetric::Size => file.bytes,
160        SummaryMetric::Complexity => file.complexity,
161    }
162}
163
164fn folder_metric_of(folder: &FolderSummary, by: SummaryMetric) -> u64 {
165    match by {
166        SummaryMetric::Tokens => folder.tokens,
167        SummaryMetric::Lines => folder.lines,
168        SummaryMetric::Size => folder.bytes,
169        SummaryMetric::Complexity => folder.complexity,
170    }
171}
172
173/// Parent directory of a path, with separators normalized to `/`.
174/// Files at the scan root map to `"."`.
175fn parent_dir(path: &str) -> String {
176    let normalized = path.replace('\\', "/");
177    match normalized.rfind('/') {
178        Some(0) => "/".to_string(),
179        Some(idx) => normalized[..idx].to_string(),
180        None => ".".to_string(),
181    }
182}
183
184/// Compute the summary from detection results.
185///
186/// `display_path` maps a source id (canonical absolute path) to the path shown
187/// in reports — the same relativization applied to clone fragments, so
188/// per-file duplication matching works on identical strings.
189pub fn compute_summary(
190    sources: &[SourceFile],
191    clones: &[CpdClone],
192    top: usize,
193    by: SummaryMetric,
194    display_path: impl Fn(&str) -> String,
195) -> Summary {
196    // Per-file duplication, keyed by display path. Both fragments of a clone
197    // count toward their file: the question here is "where does duplicated
198    // code live", not the de-duplicated total that Statistics reports.
199    let mut dup: HashMap<String, (u64, u64)> = HashMap::new();
200    for clone in clones {
201        for (fragment, unmatched) in [
202            (&clone.fragment_a, clone.unmatched_lines[0]),
203            (&clone.fragment_b, clone.unmatched_lines[1]),
204        ] {
205            // Sub-format fragments carry a `<path>:<format>` id; fold them
206            // into the parent file.
207            let path = fragment
208                .source_id
209                .strip_suffix(&format!(":{}", clone.format))
210                .unwrap_or(&fragment.source_id);
211            let entry = dup.entry(path.to_string()).or_default();
212            // Gap lines of a merged clone are not duplicated code.
213            entry.0 += fragment
214                .end
215                .line
216                .saturating_sub(fragment.start.line)
217                .saturating_sub(unmatched) as u64;
218            entry.1 += clone.token_count as u64;
219        }
220    }
221
222    let mut files: Vec<FileSummary> = sources
223        .iter()
224        .filter(|s| !is_synthetic(s))
225        .map(|source| {
226            let path = display_path(&source.id);
227            // Same line metric as Statistics: max token start line.
228            let lines = source
229                .tokens
230                .iter()
231                .map(|t| t.start.line)
232                .max()
233                .unwrap_or(0) as u64;
234            let decisions = source
235                .tokens
236                .iter()
237                .filter(|t| is_decision_token(&t.value))
238                .count() as u64;
239            let (duplicated_lines, duplicated_tokens) = dup.get(&path).copied().unwrap_or_default();
240            FileSummary {
241                lines,
242                tokens: source.tokens.len() as u64,
243                bytes: source.bytes,
244                duplicated_lines,
245                duplicated_tokens,
246                complexity: 1 + decisions,
247                format: source.format.clone(),
248                path,
249            }
250        })
251        .collect();
252
253    let total_files = files.len() as u64;
254
255    // Folder rollup over ALL files (before top-N truncation).
256    let mut folder_map: HashMap<String, FolderSummary> = HashMap::new();
257    for file in &files {
258        let dir = parent_dir(&file.path);
259        let entry = folder_map
260            .entry(dir.clone())
261            .or_insert_with(|| FolderSummary {
262                path: dir,
263                files: 0,
264                lines: 0,
265                tokens: 0,
266                bytes: 0,
267                duplicated_lines: 0,
268                complexity: 0,
269            });
270        entry.files += 1;
271        entry.lines += file.lines;
272        entry.tokens += file.tokens;
273        entry.bytes += file.bytes;
274        entry.duplicated_lines += file.duplicated_lines;
275        entry.complexity += file.complexity;
276    }
277    let total_folders = folder_map.len() as u64;
278
279    // Top-N files by the primary metric: `--summary-top N` always yields at
280    // most N rows (least surprise). Other lenses are one `--summary-by` away;
281    // every row still carries all metrics.
282    files.sort_by(|a, b| {
283        metric_of(b, by)
284            .cmp(&metric_of(a, by))
285            .then_with(|| a.path.cmp(&b.path))
286    });
287    files.truncate(top);
288
289    let mut folders: Vec<FolderSummary> = folder_map.into_values().collect();
290    folders.sort_by(|a, b| {
291        folder_metric_of(b, by)
292            .cmp(&folder_metric_of(a, by))
293            .then_with(|| a.path.cmp(&b.path))
294    });
295    folders.truncate(top);
296
297    Summary {
298        by,
299        files,
300        folders,
301        total_files,
302        total_folders,
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::models::{CpdClone, Fragment, Location, Token, TokenKind};
310
311    fn loc(line: u32) -> Location {
312        Location {
313            line,
314            column: 0,
315            offset: 0,
316        }
317    }
318
319    fn token(value: &str, line: u32) -> Token {
320        Token {
321            kind: TokenKind::Keyword,
322            value: value.to_string(),
323            start: loc(line),
324            end: loc(line),
325        }
326    }
327
328    fn source(id: &str, format: &str, values: &[&str], bytes: u64) -> SourceFile {
329        SourceFile {
330            id: id.to_string(),
331            format: format.to_string(),
332            tokens: values
333                .iter()
334                .enumerate()
335                .map(|(i, v)| token(v, i as u32 + 1))
336                .collect(),
337            bytes,
338        }
339    }
340
341    fn clone_between(format: &str, a: &str, b: &str, lines: u32, tokens: u32) -> CpdClone {
342        let fragment = |id: &str| Fragment {
343            source_id: id.to_string(),
344            source_root: None,
345            start: loc(1),
346            end: loc(1 + lines),
347            range: [0, tokens],
348            blame: None,
349        };
350        CpdClone {
351            format: format.to_string(),
352            fragment_a: fragment(a),
353            fragment_b: fragment(b),
354            token_count: tokens,
355            is_new: false,
356            kind: Default::default(),
357            similarity: None,
358            similarity_method: None,
359            unmatched_lines: [0, 0],
360        }
361    }
362
363    fn identity(path: &str) -> String {
364        path.to_string()
365    }
366
367    #[test]
368    fn empty_input_produces_empty_summary() {
369        let summary = compute_summary(&[], &[], 10, SummaryMetric::Tokens, identity);
370        assert!(summary.files.is_empty());
371        assert!(summary.folders.is_empty());
372        assert_eq!(summary.total_files, 0);
373        assert_eq!(summary.total_folders, 0);
374    }
375
376    #[test]
377    fn files_sorted_by_primary_metric() {
378        let sources = vec![
379            source("src/small.js", "javascript", &["a", "b"], 10),
380            source("src/big.js", "javascript", &["a", "b", "c", "d"], 20),
381        ];
382        let summary = compute_summary(&sources, &[], 10, SummaryMetric::Tokens, identity);
383        assert_eq!(summary.files[0].path, "src/big.js");
384        assert_eq!(summary.files[0].tokens, 4);
385        assert_eq!(summary.total_files, 2);
386    }
387
388    #[test]
389    fn top_n_is_exact_row_count_by_primary_metric() {
390        // huge.js wins on tokens, fat.js wins on size — top=1 by tokens must
391        // yield exactly one row: huge.js. `--summary-top N` never surprises
392        // with more than N rows; other metrics are served by --summary-by.
393        let sources = vec![
394            source("huge.js", "javascript", &["a", "b", "c", "d", "e"], 1),
395            source("fat.js", "javascript", &["a"], 9999),
396        ];
397        let summary = compute_summary(&sources, &[], 1, SummaryMetric::Tokens, identity);
398        assert_eq!(summary.files.len(), 1);
399        assert_eq!(summary.files[0].path, "huge.js");
400        assert_eq!(summary.total_files, 2, "truncation stays visible");
401
402        let by_size = compute_summary(&sources, &[], 1, SummaryMetric::Size, identity);
403        assert_eq!(by_size.files[0].path, "fat.js");
404    }
405
406    #[test]
407    fn complexity_counts_decision_tokens() {
408        let sources = vec![source(
409            "a.js",
410            "javascript",
411            &["if", "x", "&&", "y", "for", "z", "else"],
412            10,
413        )];
414        let summary = compute_summary(&sources, &[], 10, SummaryMetric::Complexity, identity);
415        // 1 + (if, &&, for) = 4; "else" is not a decision point.
416        assert_eq!(summary.files[0].complexity, 4);
417    }
418
419    #[test]
420    fn complexity_is_case_insensitive() {
421        // SQL / PL/SQL / Fortran style uppercase keywords.
422        let sources = vec![source(
423            "a.sql",
424            "sql",
425            &["IF", "x", "OR", "y", "WHEN", "THEN", "If"],
426            10,
427        )];
428        let summary = compute_summary(&sources, &[], 10, SummaryMetric::Complexity, identity);
429        // 1 + (IF, OR, WHEN, If) = 5; THEN is not a decision point.
430        assert_eq!(summary.files[0].complexity, 5);
431    }
432
433    #[test]
434    fn decision_token_edge_cases() {
435        assert!(is_decision_token("unless"));
436        assert!(is_decision_token("ELSEIF"));
437        assert!(is_decision_token("andalso"));
438        assert!(!is_decision_token(""));
439        assert!(!is_decision_token("iffy"));
440        assert!(!is_decision_token("conditionally"), "length-capped");
441        assert!(!is_decision_token("форматирование"), "non-ASCII ignored");
442    }
443
444    #[test]
445    fn folder_rollup_uses_direct_parent() {
446        let sources = vec![
447            source("src/app/a.js", "javascript", &["x"], 5),
448            source("src/app/b.js", "javascript", &["x", "y"], 5),
449            source("src/c.js", "javascript", &["x"], 5),
450            source("root.js", "javascript", &["x"], 5),
451        ];
452        let summary = compute_summary(&sources, &[], 10, SummaryMetric::Tokens, identity);
453        assert_eq!(summary.total_folders, 3);
454        let app = summary
455            .folders
456            .iter()
457            .find(|f| f.path == "src/app")
458            .expect("src/app folder");
459        assert_eq!(app.files, 2);
460        assert_eq!(app.tokens, 3);
461        let root = summary.folders.iter().find(|f| f.path == ".");
462        assert!(root.is_some(), "root files grouped under '.'");
463    }
464
465    #[test]
466    fn duplication_attributed_to_both_fragments() {
467        let sources = vec![
468            source("a.js", "javascript", &["x", "y", "z"], 5),
469            source("b.js", "javascript", &["x", "y", "z"], 5),
470        ];
471        let clones = vec![clone_between("javascript", "a.js", "b.js", 9, 30)];
472        let summary = compute_summary(&sources, &clones, 10, SummaryMetric::Tokens, identity);
473        for path in ["a.js", "b.js"] {
474            let file = summary.files.iter().find(|f| f.path == path).unwrap();
475            assert_eq!(file.duplicated_lines, 9, "{path} duplicated lines");
476            assert_eq!(file.duplicated_tokens, 30, "{path} duplicated tokens");
477        }
478    }
479
480    #[test]
481    fn synthetic_sub_format_sources_are_skipped() {
482        let sources = vec![
483            source("doc.md", "markdown", &["x", "y"], 100),
484            source("doc.md:javascript", "javascript", &["x"], 0),
485        ];
486        let summary = compute_summary(&sources, &[], 10, SummaryMetric::Tokens, identity);
487        assert_eq!(summary.total_files, 1);
488        assert_eq!(summary.files[0].path, "doc.md");
489    }
490
491    #[test]
492    fn sub_format_clone_folds_into_parent_file() {
493        let sources = vec![source("doc.md", "markdown", &["x", "y"], 100)];
494        let clones = vec![clone_between(
495            "javascript",
496            "doc.md:javascript",
497            "doc.md:javascript",
498            4,
499            20,
500        )];
501        let summary = compute_summary(&sources, &clones, 10, SummaryMetric::Tokens, identity);
502        assert_eq!(
503            summary.files[0].duplicated_lines, 8,
504            "both fragments fold in"
505        );
506    }
507
508    #[test]
509    fn gap_lines_of_a_merged_clone_stay_out_of_file_duplication() {
510        let sources = vec![
511            source("a.js", "javascript", &["x"; 20], 10),
512            source("b.js", "javascript", &["x"; 20], 10),
513        ];
514        let mut merged = clone_between("javascript", "a.js", "b.js", 10, 60);
515        merged.unmatched_lines = [0, 3];
516        let summary = compute_summary(&sources, &[merged], 10, SummaryMetric::Tokens, identity);
517        let dup = |path: &str| {
518            summary
519                .files
520                .iter()
521                .find(|f| f.path == path)
522                .unwrap()
523                .duplicated_lines
524        };
525        assert_eq!(dup("a.js"), 10);
526        assert_eq!(dup("b.js"), 7, "three gap lines in b are not duplicated");
527    }
528
529    #[test]
530    fn display_path_applied_before_dup_matching() {
531        let sources = vec![source("/abs/root/a.js", "javascript", &["x"], 5)];
532        let clones = vec![clone_between("javascript", "a.js", "a.js", 2, 10)];
533        let summary = compute_summary(&sources, &clones, 10, SummaryMetric::Tokens, |p| {
534            p.strip_prefix("/abs/root/").unwrap_or(p).to_string()
535        });
536        assert_eq!(summary.files[0].path, "a.js");
537        assert_eq!(summary.files[0].duplicated_lines, 4);
538    }
539
540    #[test]
541    fn folders_truncated_to_top_n_but_total_reported() {
542        let sources: Vec<SourceFile> = (0..5)
543            .map(|i| source(&format!("dir{i}/f.js"), "javascript", &["x"], 1))
544            .collect();
545        let summary = compute_summary(&sources, &[], 2, SummaryMetric::Tokens, identity);
546        assert_eq!(summary.folders.len(), 2);
547        assert_eq!(summary.total_folders, 5);
548    }
549
550    #[test]
551    fn metric_parses_from_str() {
552        assert_eq!(
553            "complexity".parse::<SummaryMetric>().unwrap(),
554            SummaryMetric::Complexity
555        );
556        assert!("bogus".parse::<SummaryMetric>().is_err());
557    }
558
559    #[test]
560    fn summary_serializes_camel_case() {
561        let sources = vec![source("a.js", "javascript", &["x"], 5)];
562        let summary = compute_summary(&sources, &[], 10, SummaryMetric::Size, identity);
563        let json = serde_json::to_string(&summary).unwrap();
564        assert!(json.contains("\"totalFiles\""));
565        assert!(json.contains("\"duplicatedLines\""));
566        assert!(json.contains("\"by\":\"size\""));
567        assert!(!json.contains("total_files"));
568    }
569}