1use std::path::{Path, PathBuf};
4
5use fallow_engine::duplicates::{
6 CloneFingerprintSet, clone_fingerprint, dominant_identifier, fingerprint_for_fragment,
7};
8use fallow_output::{
9 CloneFamilyAction, CloneGroupAction, CodeClimateIssue, CodeClimateIssueInput,
10 CodeClimateSeverity, clone_family_actions, clone_group_actions, codeclimate_fingerprint_hash,
11 normalize_uri,
12};
13use fallow_types::duplicates::{
14 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, MirroredDirectory,
15 RefactoringSuggestion, clone_location_spread,
16};
17use fallow_types::envelope::AuditIntroduced;
18use fallow_types::serde_path;
19use serde::Serialize;
20
21#[derive(Debug, Clone, Serialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30pub struct AttributedInstance {
31 #[serde(flatten)]
33 pub instance: CloneInstance,
34 pub owner: String,
37}
38
39#[derive(Debug, Clone, Serialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43pub struct AttributedCloneGroup {
44 pub primary_owner: String,
47 pub token_count: usize,
49 pub line_count: usize,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 #[cfg_attr(feature = "schema", schemars(with = "f64"))]
54 pub similarity: Option<f64>,
55 pub instances: Vec<AttributedInstance>,
58}
59
60impl AttributedCloneGroup {
61 #[must_use]
63 pub fn fingerprint(&self, fingerprints: &CloneFingerprintSet) -> String {
64 let instances: Vec<_> = self
65 .instances
66 .iter()
67 .map(|instance| instance.instance.clone())
68 .collect();
69 fingerprints.fingerprint_for_parts(&instances, self.token_count, self.line_count)
70 }
71}
72
73#[derive(Debug, Clone, Serialize)]
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80pub struct AttributedCloneGroupFinding {
81 #[serde(flatten)]
83 pub group: AttributedCloneGroup,
84 pub fingerprint: String,
89 pub spread: usize,
91 pub actions: Vec<CloneGroupAction>,
93}
94
95impl AttributedCloneGroupFinding {
96 #[allow(
98 dead_code,
99 reason = "kept for focused wrapper tests and non-report construction paths"
100 )]
101 #[must_use]
102 pub fn with_actions(group: AttributedCloneGroup) -> Self {
103 let fingerprint = group.instances.first().map_or_else(
104 || fingerprint_for_fragment(""),
105 |ai| fingerprint_for_fragment(&ai.instance.fragment),
106 );
107 Self::with_fingerprint(group, fingerprint)
108 }
109
110 #[must_use]
112 pub fn with_fingerprint(group: AttributedCloneGroup, fingerprint: String) -> Self {
113 let spread = clone_location_spread(group.instances.iter().map(|instance| {
114 (
115 instance.instance.file.as_path(),
116 instance.instance.start_line,
117 instance.instance.end_line,
118 )
119 }));
120 let actions = clone_group_actions(group.line_count, group.instances.len());
121 Self {
122 group,
123 fingerprint,
124 spread,
125 actions,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Serialize)]
133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
134pub struct DuplicationGroup {
135 pub key: String,
139 pub stats: DuplicationStats,
141 pub clone_groups: Vec<AttributedCloneGroupFinding>,
146 pub clone_families: Vec<CloneFamilyFinding>,
149}
150
151#[derive(Debug, Clone, Serialize)]
153pub struct DuplicationGrouping {
154 pub mode: &'static str,
156 pub groups: Vec<DuplicationGroup>,
158}
159
160#[derive(Debug, Clone, Serialize)]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167pub struct CloneGroupFinding {
168 #[serde(flatten)]
170 pub group: CloneGroup,
171 pub fingerprint: String,
176 pub spread: usize,
178 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub suggested_name: Option<String>,
186 pub actions: Vec<CloneGroupAction>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub introduced: Option<AuditIntroduced>,
194}
195
196impl CloneGroupFinding {
197 #[allow(
199 dead_code,
200 reason = "kept for focused wrapper tests and non-report construction paths"
201 )]
202 #[must_use]
203 pub fn with_actions(group: CloneGroup) -> Self {
204 let fingerprint = clone_fingerprint(&group.instances);
205 Self::with_fingerprint(group, fingerprint)
206 }
207
208 #[must_use]
210 pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
211 let spread = group.spread();
212 let suggested_name = dominant_identifier(&group);
213 let actions = clone_group_actions(group.line_count, group.instances.len());
214 Self {
215 fingerprint,
216 spread,
217 suggested_name,
218 group,
219 actions,
220 introduced: None,
221 }
222 }
223}
224
225#[derive(Debug, Clone, Serialize)]
236#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
237pub struct CloneFamilyFinding {
238 #[serde(serialize_with = "serde_path::serialize_vec")]
240 pub files: Vec<PathBuf>,
241 pub groups: Vec<CloneGroupFinding>,
245 pub total_duplicated_lines: usize,
247 pub total_duplicated_tokens: usize,
249 pub suggestions: Vec<RefactoringSuggestion>,
251 pub actions: Vec<CloneFamilyAction>,
256}
257
258impl CloneFamilyFinding {
259 #[allow(
261 dead_code,
262 reason = "kept for focused wrapper tests and non-report construction paths"
263 )]
264 #[must_use]
265 pub fn with_actions(family: CloneFamily) -> Self {
266 let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
267 Self::with_fingerprints(family, &fingerprints)
268 }
269
270 #[must_use]
273 pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
274 let actions = build_clone_family_actions(
275 &family.groups,
276 family.total_duplicated_lines,
277 &family.suggestions,
278 );
279 Self {
280 files: family.files,
281 groups: family
282 .groups
283 .into_iter()
284 .map(|group| {
285 let fingerprint = fingerprints.fingerprint_for_group(&group);
286 CloneGroupFinding::with_fingerprint(group, fingerprint)
287 })
288 .collect(),
289 total_duplicated_lines: family.total_duplicated_lines,
290 total_duplicated_tokens: family.total_duplicated_tokens,
291 suggestions: family.suggestions,
292 actions,
293 }
294 }
295}
296
297fn build_clone_family_actions(
298 groups: &[CloneGroup],
299 total_duplicated_lines: usize,
300 suggestions: &[RefactoringSuggestion],
301) -> Vec<CloneFamilyAction> {
302 clone_family_actions(
303 groups.len(),
304 total_duplicated_lines,
305 suggestions
306 .iter()
307 .map(|suggestion| suggestion.description.as_str()),
308 )
309}
310
311#[derive(Debug, Clone, Serialize)]
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322pub struct DupesReportPayload {
323 pub clone_groups: Vec<CloneGroupFinding>,
325 pub clone_families: Vec<CloneFamilyFinding>,
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
335 pub mirrored_directories: Vec<MirroredDirectory>,
336 pub stats: DuplicationStats,
338}
339
340impl DupesReportPayload {
341 #[must_use]
343 pub fn from_report(report: &DuplicationReport) -> Self {
344 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
345 Self {
346 clone_groups: report
347 .clone_groups
348 .iter()
349 .map(|group| {
350 CloneGroupFinding::with_fingerprint(
351 group.clone(),
352 fingerprints.fingerprint_for_group(group),
353 )
354 })
355 .collect(),
356 clone_families: report
357 .clone_families
358 .iter()
359 .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
360 .collect(),
361 mirrored_directories: report.mirrored_directories.clone(),
362 stats: report.stats.clone(),
363 }
364 }
365}
366
367#[must_use]
373#[expect(
374 clippy::cast_possible_truncation,
375 reason = "line numbers are bounded by source size"
376)]
377pub fn build_duplication_codeclimate(
378 report: &DuplicationReport,
379 root: &Path,
380) -> Vec<CodeClimateIssue> {
381 let mut issues = Vec::new();
382
383 for (i, group) in report.clone_groups.iter().enumerate() {
384 let token_str = group.token_count.to_string();
385 let line_count_str = group.line_count.to_string();
386 let fragment_prefix: String = group
387 .instances
388 .first()
389 .map(|inst| inst.fragment.chars().take(64).collect())
390 .unwrap_or_default();
391
392 for instance in &group.instances {
393 let path = codeclimate_path(&instance.file, root);
394 let start_str = instance.start_line.to_string();
395 let fp = codeclimate_fingerprint_hash(&[
396 "fallow/code-duplication",
397 &path,
398 &start_str,
399 &token_str,
400 &line_count_str,
401 &fragment_prefix,
402 ]);
403 issues.push(fallow_output::build_codeclimate_issue(
404 CodeClimateIssueInput {
405 check_name: "fallow/code-duplication",
406 description: &format!(
407 "Code clone group {} ({} lines, {} instances)",
408 i + 1,
409 group.line_count,
410 group.instances.len()
411 ),
412 severity: CodeClimateSeverity::Minor,
413 category: "Duplication",
414 path: &path,
415 begin_line: Some(instance.start_line as u32),
416 fingerprint: &fp,
417 },
418 ));
419 }
420 }
421
422 issues
423}
424
425fn codeclimate_path(path: &Path, root: &Path) -> String {
426 normalize_uri(
427 &path
428 .strip_prefix(root)
429 .unwrap_or(path)
430 .display()
431 .to_string(),
432 )
433}
434
435#[cfg(test)]
436mod tests {
437 use std::path::Path;
438
439 use fallow_output::{CloneFamilyActionType, CloneGroupActionType};
440 use fallow_types::duplicates::{
441 CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
442 };
443
444 use super::*;
445
446 fn instance(path: &str) -> CloneInstance {
447 CloneInstance {
448 file: PathBuf::from(path),
449 start_line: 1,
450 end_line: 10,
451 start_col: 0,
452 end_col: 0,
453 fragment: String::new(),
454 }
455 }
456
457 fn group(instances: usize) -> CloneGroup {
458 CloneGroup {
459 instances: (0..instances)
460 .map(|i| instance(&format!("/root/file_{i}.ts")))
461 .collect(),
462 token_count: 100,
463 line_count: 20,
464 similarity: None,
465 }
466 }
467
468 #[test]
469 fn clone_group_finding_position_0_is_extract_shared() {
470 let finding = CloneGroupFinding::with_actions(group(2));
471 assert_eq!(finding.actions.len(), 2);
472 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
473 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
474 assert!(finding.introduced.is_none());
475 }
476
477 #[test]
478 fn attributed_clone_group_finding_actions_match_clone_group_shape() {
479 let attributed = AttributedCloneGroup {
480 primary_owner: "src".to_string(),
481 token_count: 100,
482 line_count: 20,
483 similarity: None,
484 instances: vec![
485 AttributedInstance {
486 instance: instance("/root/src/a.ts"),
487 owner: "src".to_string(),
488 },
489 AttributedInstance {
490 instance: instance("/root/src/b.ts"),
491 owner: "src".to_string(),
492 },
493 ],
494 };
495 let finding = AttributedCloneGroupFinding::with_actions(attributed);
496 assert_eq!(finding.actions.len(), 2);
497 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
498 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
499 }
500
501 #[test]
502 fn clone_group_finding_surfaces_dominant_identifier() {
503 let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
504 let g = CloneGroup {
505 instances: vec![
506 CloneInstance {
507 file: PathBuf::from("/root/a.ts"),
508 start_line: 1,
509 end_line: 3,
510 start_col: 0,
511 end_col: 0,
512 fragment: fragment.to_string(),
513 },
514 CloneInstance {
515 file: PathBuf::from("/root/b.ts"),
516 start_line: 1,
517 end_line: 3,
518 start_col: 0,
519 end_col: 0,
520 fragment: fragment.to_string(),
521 },
522 ],
523 token_count: 100,
524 line_count: 3,
525 similarity: None,
526 };
527 let finding = CloneGroupFinding::with_actions(g);
528 assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
529 }
530
531 #[test]
532 fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
533 let finding = CloneGroupFinding::with_actions(group(2));
534 assert!(finding.suggested_name.is_none());
535 }
536
537 #[test]
538 fn clone_group_finding_description_pluralises_instance_count() {
539 let single = CloneGroupFinding::with_actions(group(1));
540 assert!(single.actions[0].description.contains("1 instance"));
541 assert!(!single.actions[0].description.contains("1 instances"));
542 let multi = CloneGroupFinding::with_actions(group(3));
543 assert!(multi.actions[0].description.contains("3 instances"));
544 }
545
546 #[test]
547 fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
548 let family = CloneFamily {
549 files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
550 groups: vec![group(2), group(2)],
551 total_duplicated_lines: 40,
552 total_duplicated_tokens: 200,
553 suggestions: vec![
554 RefactoringSuggestion {
555 kind: RefactoringKind::ExtractFunction,
556 description: "Extract helper".to_string(),
557 estimated_savings: 10,
558 },
559 RefactoringSuggestion {
560 kind: RefactoringKind::ExtractModule,
561 description: "Extract module".to_string(),
562 estimated_savings: 30,
563 },
564 ],
565 };
566 let finding = CloneFamilyFinding::with_actions(family);
567 assert_eq!(finding.actions.len(), 4);
568 assert_eq!(
569 finding.actions[0].kind,
570 CloneFamilyActionType::ExtractShared
571 );
572 assert_eq!(
573 finding.actions[1].kind,
574 CloneFamilyActionType::ApplySuggestion
575 );
576 assert_eq!(finding.actions[1].description, "Extract helper");
577 assert_eq!(
578 finding.actions[2].kind,
579 CloneFamilyActionType::ApplySuggestion
580 );
581 assert_eq!(finding.actions[2].description, "Extract module");
582 assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
583 assert_eq!(finding.groups.len(), 2);
584 for inner in &finding.groups {
585 assert_eq!(inner.actions.len(), 2);
586 assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
587 assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
588 }
589 }
590
591 #[test]
592 fn clone_family_finding_with_no_suggestions_emits_two_actions() {
593 let family = CloneFamily {
594 files: vec![PathBuf::from("/root/a.ts")],
595 groups: vec![group(2)],
596 total_duplicated_lines: 20,
597 total_duplicated_tokens: 100,
598 suggestions: Vec::new(),
599 };
600 let finding = CloneFamilyFinding::with_actions(family);
601 assert_eq!(finding.actions.len(), 2);
602 assert_eq!(
603 finding.actions[0].kind,
604 CloneFamilyActionType::ExtractShared
605 );
606 assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
607 }
608
609 #[test]
610 fn payload_from_report_wraps_all_findings() {
611 let report = DuplicationReport {
612 clone_groups: vec![group(2), group(3)],
613 clone_families: vec![CloneFamily {
614 files: vec![PathBuf::from("/root/a.ts")],
615 groups: vec![group(2)],
616 total_duplicated_lines: 20,
617 total_duplicated_tokens: 100,
618 suggestions: Vec::new(),
619 }],
620 mirrored_directories: Vec::new(),
621 stats: DuplicationStats::default(),
622 };
623 let payload = DupesReportPayload::from_report(&report);
624 assert_eq!(payload.clone_groups.len(), 2);
625 assert_eq!(payload.clone_families.len(), 1);
626 for finding in &payload.clone_groups {
627 assert_eq!(finding.actions.len(), 2);
628 }
629 assert_eq!(payload.clone_families[0].actions.len(), 2);
630 }
631
632 #[test]
633 fn duplication_codeclimate_uses_relative_normalized_paths() {
634 let report = DuplicationReport {
635 clone_groups: vec![CloneGroup {
636 instances: vec![CloneInstance {
637 file: PathBuf::from("/root/app/[id]/page.tsx"),
638 start_line: 4,
639 end_line: 8,
640 start_col: 0,
641 end_col: 0,
642 fragment: "const duplicate = 1;".to_string(),
643 }],
644 token_count: 42,
645 line_count: 5,
646 similarity: None,
647 }],
648 clone_families: Vec::new(),
649 mirrored_directories: Vec::new(),
650 stats: DuplicationStats::default(),
651 };
652
653 let issues = build_duplication_codeclimate(&report, Path::new("/root"));
654
655 assert_eq!(issues.len(), 1);
656 let issue = &issues[0];
657 assert_eq!(issue.check_name, "fallow/code-duplication");
658 assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
659 assert_eq!(issue.location.lines.begin, 4);
660 assert_eq!(issue.categories, vec!["Duplication"]);
661 assert!(issue.description.contains("Code clone group 1"));
662 }
663}