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 in [&clone.fragment_a, &clone.fragment_b] {
202 let path = fragment
205 .source_id
206 .strip_suffix(&format!(":{}", clone.format))
207 .unwrap_or(&fragment.source_id);
208 let entry = dup.entry(path.to_string()).or_default();
209 entry.0 += fragment.end.line.saturating_sub(fragment.start.line) as u64;
210 entry.1 += clone.token_count as u64;
211 }
212 }
213
214 let mut files: Vec<FileSummary> = sources
215 .iter()
216 .filter(|s| !is_synthetic(s))
217 .map(|source| {
218 let path = display_path(&source.id);
219 let lines = source
221 .tokens
222 .iter()
223 .map(|t| t.start.line)
224 .max()
225 .unwrap_or(0) as u64;
226 let decisions = source
227 .tokens
228 .iter()
229 .filter(|t| is_decision_token(&t.value))
230 .count() as u64;
231 let (duplicated_lines, duplicated_tokens) = dup.get(&path).copied().unwrap_or_default();
232 FileSummary {
233 lines,
234 tokens: source.tokens.len() as u64,
235 bytes: source.bytes,
236 duplicated_lines,
237 duplicated_tokens,
238 complexity: 1 + decisions,
239 format: source.format.clone(),
240 path,
241 }
242 })
243 .collect();
244
245 let total_files = files.len() as u64;
246
247 let mut folder_map: HashMap<String, FolderSummary> = HashMap::new();
249 for file in &files {
250 let dir = parent_dir(&file.path);
251 let entry = folder_map
252 .entry(dir.clone())
253 .or_insert_with(|| FolderSummary {
254 path: dir,
255 files: 0,
256 lines: 0,
257 tokens: 0,
258 bytes: 0,
259 duplicated_lines: 0,
260 complexity: 0,
261 });
262 entry.files += 1;
263 entry.lines += file.lines;
264 entry.tokens += file.tokens;
265 entry.bytes += file.bytes;
266 entry.duplicated_lines += file.duplicated_lines;
267 entry.complexity += file.complexity;
268 }
269 let total_folders = folder_map.len() as u64;
270
271 files.sort_by(|a, b| {
275 metric_of(b, by)
276 .cmp(&metric_of(a, by))
277 .then_with(|| a.path.cmp(&b.path))
278 });
279 files.truncate(top);
280
281 let mut folders: Vec<FolderSummary> = folder_map.into_values().collect();
282 folders.sort_by(|a, b| {
283 folder_metric_of(b, by)
284 .cmp(&folder_metric_of(a, by))
285 .then_with(|| a.path.cmp(&b.path))
286 });
287 folders.truncate(top);
288
289 Summary {
290 by,
291 files,
292 folders,
293 total_files,
294 total_folders,
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::models::{CpdClone, Fragment, Location, Token, TokenKind};
302
303 fn loc(line: u32) -> Location {
304 Location {
305 line,
306 column: 0,
307 offset: 0,
308 }
309 }
310
311 fn token(value: &str, line: u32) -> Token {
312 Token {
313 kind: TokenKind::Keyword,
314 value: value.to_string(),
315 start: loc(line),
316 end: loc(line),
317 }
318 }
319
320 fn source(id: &str, format: &str, values: &[&str], bytes: u64) -> SourceFile {
321 SourceFile {
322 id: id.to_string(),
323 format: format.to_string(),
324 tokens: values
325 .iter()
326 .enumerate()
327 .map(|(i, v)| token(v, i as u32 + 1))
328 .collect(),
329 bytes,
330 }
331 }
332
333 fn clone_between(format: &str, a: &str, b: &str, lines: u32, tokens: u32) -> CpdClone {
334 let fragment = |id: &str| Fragment {
335 source_id: id.to_string(),
336 source_root: None,
337 start: loc(1),
338 end: loc(1 + lines),
339 range: [0, tokens],
340 blame: None,
341 };
342 CpdClone {
343 format: format.to_string(),
344 fragment_a: fragment(a),
345 fragment_b: fragment(b),
346 token_count: tokens,
347 is_new: false,
348 }
349 }
350
351 fn identity(path: &str) -> String {
352 path.to_string()
353 }
354
355 #[test]
356 fn empty_input_produces_empty_summary() {
357 let summary = compute_summary(&[], &[], 10, SummaryMetric::Tokens, identity);
358 assert!(summary.files.is_empty());
359 assert!(summary.folders.is_empty());
360 assert_eq!(summary.total_files, 0);
361 assert_eq!(summary.total_folders, 0);
362 }
363
364 #[test]
365 fn files_sorted_by_primary_metric() {
366 let sources = vec![
367 source("src/small.js", "javascript", &["a", "b"], 10),
368 source("src/big.js", "javascript", &["a", "b", "c", "d"], 20),
369 ];
370 let summary = compute_summary(&sources, &[], 10, SummaryMetric::Tokens, identity);
371 assert_eq!(summary.files[0].path, "src/big.js");
372 assert_eq!(summary.files[0].tokens, 4);
373 assert_eq!(summary.total_files, 2);
374 }
375
376 #[test]
377 fn top_n_is_exact_row_count_by_primary_metric() {
378 let sources = vec![
382 source("huge.js", "javascript", &["a", "b", "c", "d", "e"], 1),
383 source("fat.js", "javascript", &["a"], 9999),
384 ];
385 let summary = compute_summary(&sources, &[], 1, SummaryMetric::Tokens, identity);
386 assert_eq!(summary.files.len(), 1);
387 assert_eq!(summary.files[0].path, "huge.js");
388 assert_eq!(summary.total_files, 2, "truncation stays visible");
389
390 let by_size = compute_summary(&sources, &[], 1, SummaryMetric::Size, identity);
391 assert_eq!(by_size.files[0].path, "fat.js");
392 }
393
394 #[test]
395 fn complexity_counts_decision_tokens() {
396 let sources = vec![source(
397 "a.js",
398 "javascript",
399 &["if", "x", "&&", "y", "for", "z", "else"],
400 10,
401 )];
402 let summary = compute_summary(&sources, &[], 10, SummaryMetric::Complexity, identity);
403 assert_eq!(summary.files[0].complexity, 4);
405 }
406
407 #[test]
408 fn complexity_is_case_insensitive() {
409 let sources = vec![source(
411 "a.sql",
412 "sql",
413 &["IF", "x", "OR", "y", "WHEN", "THEN", "If"],
414 10,
415 )];
416 let summary = compute_summary(&sources, &[], 10, SummaryMetric::Complexity, identity);
417 assert_eq!(summary.files[0].complexity, 5);
419 }
420
421 #[test]
422 fn decision_token_edge_cases() {
423 assert!(is_decision_token("unless"));
424 assert!(is_decision_token("ELSEIF"));
425 assert!(is_decision_token("andalso"));
426 assert!(!is_decision_token(""));
427 assert!(!is_decision_token("iffy"));
428 assert!(!is_decision_token("conditionally"), "length-capped");
429 assert!(!is_decision_token("форматирование"), "non-ASCII ignored");
430 }
431
432 #[test]
433 fn folder_rollup_uses_direct_parent() {
434 let sources = vec![
435 source("src/app/a.js", "javascript", &["x"], 5),
436 source("src/app/b.js", "javascript", &["x", "y"], 5),
437 source("src/c.js", "javascript", &["x"], 5),
438 source("root.js", "javascript", &["x"], 5),
439 ];
440 let summary = compute_summary(&sources, &[], 10, SummaryMetric::Tokens, identity);
441 assert_eq!(summary.total_folders, 3);
442 let app = summary
443 .folders
444 .iter()
445 .find(|f| f.path == "src/app")
446 .expect("src/app folder");
447 assert_eq!(app.files, 2);
448 assert_eq!(app.tokens, 3);
449 let root = summary.folders.iter().find(|f| f.path == ".");
450 assert!(root.is_some(), "root files grouped under '.'");
451 }
452
453 #[test]
454 fn duplication_attributed_to_both_fragments() {
455 let sources = vec![
456 source("a.js", "javascript", &["x", "y", "z"], 5),
457 source("b.js", "javascript", &["x", "y", "z"], 5),
458 ];
459 let clones = vec![clone_between("javascript", "a.js", "b.js", 9, 30)];
460 let summary = compute_summary(&sources, &clones, 10, SummaryMetric::Tokens, identity);
461 for path in ["a.js", "b.js"] {
462 let file = summary.files.iter().find(|f| f.path == path).unwrap();
463 assert_eq!(file.duplicated_lines, 9, "{path} duplicated lines");
464 assert_eq!(file.duplicated_tokens, 30, "{path} duplicated tokens");
465 }
466 }
467
468 #[test]
469 fn synthetic_sub_format_sources_are_skipped() {
470 let sources = vec![
471 source("doc.md", "markdown", &["x", "y"], 100),
472 source("doc.md:javascript", "javascript", &["x"], 0),
473 ];
474 let summary = compute_summary(&sources, &[], 10, SummaryMetric::Tokens, identity);
475 assert_eq!(summary.total_files, 1);
476 assert_eq!(summary.files[0].path, "doc.md");
477 }
478
479 #[test]
480 fn sub_format_clone_folds_into_parent_file() {
481 let sources = vec![source("doc.md", "markdown", &["x", "y"], 100)];
482 let clones = vec![clone_between(
483 "javascript",
484 "doc.md:javascript",
485 "doc.md:javascript",
486 4,
487 20,
488 )];
489 let summary = compute_summary(&sources, &clones, 10, SummaryMetric::Tokens, identity);
490 assert_eq!(
491 summary.files[0].duplicated_lines, 8,
492 "both fragments fold in"
493 );
494 }
495
496 #[test]
497 fn display_path_applied_before_dup_matching() {
498 let sources = vec![source("/abs/root/a.js", "javascript", &["x"], 5)];
499 let clones = vec![clone_between("javascript", "a.js", "a.js", 2, 10)];
500 let summary = compute_summary(&sources, &clones, 10, SummaryMetric::Tokens, |p| {
501 p.strip_prefix("/abs/root/").unwrap_or(p).to_string()
502 });
503 assert_eq!(summary.files[0].path, "a.js");
504 assert_eq!(summary.files[0].duplicated_lines, 4);
505 }
506
507 #[test]
508 fn folders_truncated_to_top_n_but_total_reported() {
509 let sources: Vec<SourceFile> = (0..5)
510 .map(|i| source(&format!("dir{i}/f.js"), "javascript", &["x"], 1))
511 .collect();
512 let summary = compute_summary(&sources, &[], 2, SummaryMetric::Tokens, identity);
513 assert_eq!(summary.folders.len(), 2);
514 assert_eq!(summary.total_folders, 5);
515 }
516
517 #[test]
518 fn metric_parses_from_str() {
519 assert_eq!(
520 "complexity".parse::<SummaryMetric>().unwrap(),
521 SummaryMetric::Complexity
522 );
523 assert!("bogus".parse::<SummaryMetric>().is_err());
524 }
525
526 #[test]
527 fn summary_serializes_camel_case() {
528 let sources = vec![source("a.js", "javascript", &["x"], 5)];
529 let summary = compute_summary(&sources, &[], 10, SummaryMetric::Size, identity);
530 let json = serde_json::to_string(&summary).unwrap();
531 assert!(json.contains("\"totalFiles\""));
532 assert!(json.contains("\"duplicatedLines\""));
533 assert!(json.contains("\"by\":\"size\""));
534 assert!(!json.contains("total_files"));
535 }
536}