1use crate::models::{CpdClone, SourceFile};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[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#[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 pub complexity: u64,
64}
65
66#[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 pub complexity: u64,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct Summary {
86 pub by: SummaryMetric,
88 pub files: Vec<FileSummary>,
92 pub folders: Vec<FolderSummary>,
94 pub total_files: u64,
96 pub total_folders: u64,
98}
99
100fn 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
145fn 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
173fn 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
184pub 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 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 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 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 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 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 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 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 assert_eq!(summary.files[0].complexity, 4);
417 }
418
419 #[test]
420 fn complexity_is_case_insensitive() {
421 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 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}