1use std::path::{Path, PathBuf};
4
5use fallow_engine::duplicates::{CloneFingerprintSet, dominant_identifier};
6use fallow_output::{
7 CloneFamilyAction, CloneGroupAction, CodeClimateIssue, CodeClimateIssueInput,
8 CodeClimateSeverity, clone_family_actions, clone_group_actions, codeclimate_fingerprint_hash,
9 normalize_uri,
10};
11use fallow_types::duplicates::{
12 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, MirroredDirectory,
13 RefactoringSuggestion, clone_location_spread,
14};
15use fallow_types::envelope::AuditIntroduced;
16use fallow_types::serde_path;
17use serde::Serialize;
18
19#[derive(Debug, Clone, Serialize)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28pub struct AttributedInstance {
29 #[serde(flatten)]
31 pub instance: CloneInstance,
32 pub owner: String,
35}
36
37#[derive(Debug, Clone, Serialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub struct AttributedCloneGroup {
42 pub primary_owner: String,
45 pub token_count: usize,
47 pub line_count: usize,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 #[cfg_attr(feature = "schema", schemars(with = "f64"))]
52 pub similarity: Option<f64>,
53 pub instances: Vec<AttributedInstance>,
56}
57
58impl AttributedCloneGroup {
59 #[must_use]
61 pub fn fingerprint(&self, fingerprints: &CloneFingerprintSet) -> String {
62 let instances: Vec<_> = self
63 .instances
64 .iter()
65 .map(|instance| instance.instance.clone())
66 .collect();
67 fingerprints.fingerprint_for_parts(&instances, self.token_count, self.line_count)
68 }
69}
70
71#[derive(Debug, Clone, Serialize)]
77#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
78pub struct AttributedCloneGroupFinding {
79 #[serde(flatten)]
81 pub group: AttributedCloneGroup,
82 pub fingerprint: String,
87 pub spread: usize,
89 pub actions: Vec<CloneGroupAction>,
91}
92
93impl AttributedCloneGroupFinding {
94 #[must_use]
96 pub fn with_fingerprint(group: AttributedCloneGroup, fingerprint: String) -> Self {
97 let spread = clone_location_spread(group.instances.iter().map(|instance| {
98 (
99 instance.instance.file.as_path(),
100 instance.instance.start_line,
101 instance.instance.end_line,
102 )
103 }));
104 let actions = clone_group_actions(group.line_count, group.instances.len());
105 Self {
106 group,
107 fingerprint,
108 spread,
109 actions,
110 }
111 }
112}
113
114#[derive(Debug, Clone, Serialize)]
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118pub struct DuplicationGroup {
119 pub key: String,
123 pub stats: DuplicationStats,
125 pub clone_groups: Vec<AttributedCloneGroupFinding>,
130 pub clone_families: Vec<CloneFamilyFinding>,
133}
134
135impl DuplicationGroup {
136 pub fn strip_fragments(&mut self) {
139 for finding in &mut self.clone_groups {
140 for instance in &mut finding.group.instances {
141 instance.instance.fragment.clear();
142 }
143 }
144 for family in &mut self.clone_families {
145 for finding in &mut family.groups {
146 finding.group.strip_fragments();
147 }
148 }
149 }
150}
151
152#[derive(Debug, Clone, Serialize)]
154pub struct DuplicationGrouping {
155 pub mode: &'static str,
157 pub groups: Vec<DuplicationGroup>,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169#[serde(rename_all = "kebab-case")]
170pub enum CloneDemotionReason {
171 NoAddedLines,
175}
176
177impl CloneDemotionReason {
178 #[must_use]
181 pub fn wire_name(self) -> String {
182 match serde_json::to_value(self) {
183 Ok(serde_json::Value::String(name)) => name,
184 _ => String::new(),
185 }
186 }
187}
188
189#[derive(Debug, Clone, Serialize)]
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196pub struct CloneGroupFinding {
197 #[serde(flatten)]
199 pub group: CloneGroup,
200 pub fingerprint: String,
205 pub spread: usize,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub suggested_name: Option<String>,
215 pub actions: Vec<CloneGroupAction>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub introduced: Option<AuditIntroduced>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub demotion_reason: Option<CloneDemotionReason>,
229}
230
231impl CloneGroupFinding {
232 #[must_use]
234 pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
235 let spread = group.spread();
236 let suggested_name = dominant_identifier(&group);
237 let actions = clone_group_actions(group.line_count, group.instances.len());
238 Self {
239 fingerprint,
240 spread,
241 suggested_name,
242 group,
243 actions,
244 introduced: None,
245 demotion_reason: None,
246 }
247 }
248}
249
250#[derive(Debug, Clone, Serialize)]
261#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
262pub struct CloneFamilyFinding {
263 #[serde(serialize_with = "serde_path::serialize_vec")]
265 pub files: Vec<PathBuf>,
266 pub groups: Vec<CloneGroupFinding>,
270 pub total_duplicated_lines: usize,
272 pub total_duplicated_tokens: usize,
274 pub suggestions: Vec<RefactoringSuggestion>,
276 pub actions: Vec<CloneFamilyAction>,
281}
282
283impl CloneFamilyFinding {
284 #[must_use]
287 pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
288 let actions = build_clone_family_actions(
289 &family.groups,
290 family.total_duplicated_lines,
291 &family.suggestions,
292 );
293 Self {
294 files: family.files,
295 groups: family
296 .groups
297 .into_iter()
298 .map(|group| {
299 let fingerprint = fingerprints.fingerprint_for_group(&group);
300 CloneGroupFinding::with_fingerprint(group, fingerprint)
301 })
302 .collect(),
303 total_duplicated_lines: family.total_duplicated_lines,
304 total_duplicated_tokens: family.total_duplicated_tokens,
305 suggestions: family.suggestions,
306 actions,
307 }
308 }
309}
310
311fn build_clone_family_actions(
312 groups: &[CloneGroup],
313 total_duplicated_lines: usize,
314 suggestions: &[RefactoringSuggestion],
315) -> Vec<CloneFamilyAction> {
316 clone_family_actions(
317 groups.len(),
318 total_duplicated_lines,
319 suggestions
320 .iter()
321 .map(|suggestion| suggestion.description.as_str()),
322 )
323}
324
325#[derive(Debug, Clone, Serialize)]
335#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
336pub struct DupesReportPayload {
337 pub clone_groups: Vec<CloneGroupFinding>,
339 pub clone_families: Vec<CloneFamilyFinding>,
347 #[serde(default, skip_serializing_if = "Vec::is_empty")]
349 pub mirrored_directories: Vec<MirroredDirectory>,
350 pub stats: DuplicationStats,
352}
353
354#[derive(Debug, Clone, Serialize)]
360#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
361pub struct CombinedDupesSection {
362 #[serde(flatten)]
364 pub payload: DupesReportPayload,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub baseline_staleness: Option<fallow_output::BaselineStaleness>,
371}
372
373impl DupesReportPayload {
374 #[must_use]
376 pub fn from_report(report: &DuplicationReport) -> Self {
377 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
378 Self {
379 clone_groups: report
380 .clone_groups
381 .iter()
382 .map(|group| {
383 CloneGroupFinding::with_fingerprint(
384 group.clone(),
385 fingerprints.fingerprint_for_group(group),
386 )
387 })
388 .collect(),
389 clone_families: report
390 .clone_families
391 .iter()
392 .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
393 .collect(),
394 mirrored_directories: report.mirrored_directories.clone(),
395 stats: report.stats.clone(),
396 }
397 }
398
399 #[must_use]
406 pub fn from_report_with_fragments(report: &DuplicationReport, include_fragments: bool) -> Self {
407 let mut payload = Self::from_report(report);
408 if !include_fragments {
409 payload.strip_fragments();
410 }
411 payload
412 }
413
414 pub fn strip_fragments(&mut self) {
417 for finding in &mut self.clone_groups {
418 finding.group.strip_fragments();
419 }
420 for family in &mut self.clone_families {
421 for finding in &mut family.groups {
422 finding.group.strip_fragments();
423 }
424 }
425 }
426}
427
428#[must_use]
434#[expect(
435 clippy::cast_possible_truncation,
436 reason = "line numbers are bounded by source size"
437)]
438pub fn build_duplication_codeclimate(
439 report: &DuplicationReport,
440 root: &Path,
441) -> Vec<CodeClimateIssue> {
442 let mut issues = Vec::new();
443 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
444
445 for group in &report.clone_groups {
446 let clone_fingerprint = fingerprints.fingerprint_for_group(group);
447 let token_str = group.token_count.to_string();
448 let line_count_str = group.line_count.to_string();
449 let fragment_prefix: String = group
450 .instances
451 .first()
452 .map(|inst| inst.fragment.chars().take(64).collect())
453 .unwrap_or_default();
454
455 for (instance_index, instance) in group.instances.iter().enumerate() {
456 let path = codeclimate_path(&instance.file, root);
457 let start_str = instance.start_line.to_string();
458 let fp = codeclimate_fingerprint_hash(&[
459 "fallow/code-duplication",
460 &path,
461 &start_str,
462 &token_str,
463 &line_count_str,
464 &fragment_prefix,
465 ]);
466 let mut issue = fallow_output::build_codeclimate_issue(CodeClimateIssueInput {
467 check_name: "fallow/code-duplication",
468 description: &format!(
469 "Code clone {clone_fingerprint} ({} lines, {} instances)",
470 group.line_count,
471 group.instances.len()
472 ),
473 severity: CodeClimateSeverity::Minor,
474 category: "Duplication",
475 path: &path,
476 begin_line: Some(instance.start_line as u32),
477 fingerprint: &fp,
478 });
479 issue.location.lines.end = Some(instance.end_line as u32);
480 issue.other_locations = group
481 .instances
482 .iter()
483 .enumerate()
484 .filter(|(peer_index, _)| *peer_index != instance_index)
485 .map(|(_, peer)| fallow_output::CodeClimateLocation {
486 path: codeclimate_path(&peer.file, root),
487 lines: fallow_output::CodeClimateLines {
488 begin: peer.start_line as u32,
489 end: Some(peer.end_line as u32),
490 },
491 })
492 .collect();
493 issue.other_locations.sort_by(|a, b| {
494 (&a.path, a.lines.begin, a.lines.end).cmp(&(&b.path, b.lines.begin, b.lines.end))
495 });
496 issues.push(issue);
497 }
498 }
499
500 issues
501}
502
503fn codeclimate_path(path: &Path, root: &Path) -> String {
504 normalize_uri(
505 &path
506 .strip_prefix(root)
507 .unwrap_or(path)
508 .display()
509 .to_string(),
510 )
511}
512
513#[cfg(test)]
514mod tests {
515 use std::path::Path;
516
517 use fallow_output::{CloneFamilyActionType, CloneGroupActionType};
518 use fallow_types::duplicates::{
519 CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
520 };
521
522 use super::*;
523
524 const TEST_FINGERPRINT: &str = "dup:00000000";
525
526 fn instance(path: &str) -> CloneInstance {
527 CloneInstance {
528 is_symlink: false,
529 file: PathBuf::from(path),
530 start_line: 1,
531 end_line: 10,
532 start_col: 0,
533 end_col: 0,
534 fragment: String::new(),
535 }
536 }
537
538 fn group(instances: usize) -> CloneGroup {
539 CloneGroup {
540 instances: (0..instances)
541 .map(|i| instance(&format!("/root/file_{i}.ts")))
542 .collect(),
543 token_count: 100,
544 line_count: 20,
545 similarity: None,
546 }
547 }
548
549 #[test]
550 fn clone_group_finding_position_0_is_extract_shared() {
551 let finding = CloneGroupFinding::with_fingerprint(group(2), TEST_FINGERPRINT.to_string());
552 assert_eq!(finding.actions.len(), 2);
553 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
554 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
555 assert!(finding.introduced.is_none());
556 assert!(finding.demotion_reason.is_none());
557 }
558
559 #[test]
560 fn clone_group_finding_omits_audit_only_fields_outside_audit() {
561 let finding = CloneGroupFinding::with_fingerprint(group(2), TEST_FINGERPRINT.to_string());
564 let value = serde_json::to_value(&finding).expect("finding serializes");
565 assert!(value.get("introduced").is_none());
566 assert!(value.get("demotion_reason").is_none());
567 }
568
569 #[test]
570 fn suppressed_fragments_leave_locations_and_fingerprints_intact() {
571 let mut with_text = group(2);
572 for (index, instance) in with_text.instances.iter_mut().enumerate() {
573 instance.fragment = format!("const shared{index} = compute(input);");
574 }
575 let family = CloneFamily {
576 files: vec![PathBuf::from("/root/file_0.ts")],
577 groups: vec![with_text.clone()],
578 total_duplicated_lines: 20,
579 total_duplicated_tokens: 100,
580 suggestions: Vec::new(),
581 };
582 let report = DuplicationReport {
583 clone_groups: vec![with_text],
584 clone_families: vec![family],
585 mirrored_directories: Vec::new(),
586 stats: DuplicationStats::default(),
587 };
588
589 let kept = DupesReportPayload::from_report_with_fragments(&report, true);
590 let dropped = DupesReportPayload::from_report_with_fragments(&report, false);
591
592 assert_eq!(
593 kept.clone_groups[0].fingerprint, dropped.clone_groups[0].fingerprint,
594 "fingerprints are computed before the text is dropped"
595 );
596
597 let kept_value = serde_json::to_value(&kept).expect("payload serializes");
598 assert!(kept_value["clone_groups"][0]["instances"][0]["fragment"].is_string());
599
600 let value = serde_json::to_value(&dropped).expect("payload serializes");
601 let instance = &value["clone_groups"][0]["instances"][0];
602 assert!(
603 instance.get("fragment").is_none(),
604 "the verbatim source text must be absent, not empty"
605 );
606 assert!(instance.get("file").is_some());
607 assert!(instance.get("start_line").is_some());
608 assert!(instance.get("end_col").is_some());
609 assert!(
610 value["clone_families"][0]["groups"][0]["instances"][0]
611 .get("fragment")
612 .is_none(),
613 "nested clone-family copies carry the same text and must be dropped too"
614 );
615 }
616
617 #[test]
618 fn clone_demotion_reason_wire_name_matches_serde_representation() {
619 let reason = CloneDemotionReason::NoAddedLines;
620 assert_eq!(
621 serde_json::to_value(reason).expect("reason serializes"),
622 serde_json::Value::String(reason.wire_name())
623 );
624 assert_eq!(reason.wire_name(), "no-added-lines");
625 }
626
627 #[test]
628 fn attributed_clone_group_finding_actions_match_clone_group_shape() {
629 let attributed = AttributedCloneGroup {
630 primary_owner: "src".to_string(),
631 token_count: 100,
632 line_count: 20,
633 similarity: None,
634 instances: vec![
635 AttributedInstance {
636 instance: instance("/root/src/a.ts"),
637 owner: "src".to_string(),
638 },
639 AttributedInstance {
640 instance: instance("/root/src/b.ts"),
641 owner: "src".to_string(),
642 },
643 ],
644 };
645 let finding =
646 AttributedCloneGroupFinding::with_fingerprint(attributed, TEST_FINGERPRINT.to_string());
647 assert_eq!(finding.actions.len(), 2);
648 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
649 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
650 }
651
652 #[test]
653 fn clone_group_finding_surfaces_dominant_identifier() {
654 let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
655 let g = CloneGroup {
656 instances: vec![
657 CloneInstance {
658 is_symlink: false,
659 file: PathBuf::from("/root/a.ts"),
660 start_line: 1,
661 end_line: 3,
662 start_col: 0,
663 end_col: 0,
664 fragment: fragment.to_string(),
665 },
666 CloneInstance {
667 is_symlink: false,
668 file: PathBuf::from("/root/b.ts"),
669 start_line: 1,
670 end_line: 3,
671 start_col: 0,
672 end_col: 0,
673 fragment: fragment.to_string(),
674 },
675 ],
676 token_count: 100,
677 line_count: 3,
678 similarity: None,
679 };
680 let finding = CloneGroupFinding::with_fingerprint(g, TEST_FINGERPRINT.to_string());
681 assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
682 }
683
684 #[test]
685 fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
686 let finding = CloneGroupFinding::with_fingerprint(group(2), TEST_FINGERPRINT.to_string());
687 assert!(finding.suggested_name.is_none());
688 }
689
690 #[test]
691 fn clone_group_finding_description_pluralises_instance_count() {
692 let single = CloneGroupFinding::with_fingerprint(group(1), TEST_FINGERPRINT.to_string());
693 assert!(single.actions[0].description.contains("1 instance"));
694 assert!(!single.actions[0].description.contains("1 instances"));
695 let multi = CloneGroupFinding::with_fingerprint(group(3), TEST_FINGERPRINT.to_string());
696 assert!(multi.actions[0].description.contains("3 instances"));
697 }
698
699 #[test]
700 fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
701 let family = CloneFamily {
702 files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
703 groups: vec![group(2), group(2)],
704 total_duplicated_lines: 40,
705 total_duplicated_tokens: 200,
706 suggestions: vec![
707 RefactoringSuggestion {
708 kind: RefactoringKind::ExtractFunction,
709 description: "Extract helper".to_string(),
710 estimated_savings: 10,
711 },
712 RefactoringSuggestion {
713 kind: RefactoringKind::ExtractModule,
714 description: "Extract module".to_string(),
715 estimated_savings: 30,
716 },
717 ],
718 };
719 let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
720 let finding = CloneFamilyFinding::with_fingerprints(family, &fingerprints);
721 assert_eq!(finding.actions.len(), 4);
722 assert_eq!(
723 finding.actions[0].kind,
724 CloneFamilyActionType::ExtractShared
725 );
726 assert_eq!(
727 finding.actions[1].kind,
728 CloneFamilyActionType::ApplySuggestion
729 );
730 assert_eq!(finding.actions[1].description, "Extract helper");
731 assert_eq!(
732 finding.actions[2].kind,
733 CloneFamilyActionType::ApplySuggestion
734 );
735 assert_eq!(finding.actions[2].description, "Extract module");
736 assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
737 assert_eq!(finding.groups.len(), 2);
738 for inner in &finding.groups {
739 assert_eq!(inner.actions.len(), 2);
740 assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
741 assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
742 }
743 }
744
745 #[test]
746 fn clone_family_finding_with_no_suggestions_emits_two_actions() {
747 let family = CloneFamily {
748 files: vec![PathBuf::from("/root/a.ts")],
749 groups: vec![group(2)],
750 total_duplicated_lines: 20,
751 total_duplicated_tokens: 100,
752 suggestions: Vec::new(),
753 };
754 let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
755 let finding = CloneFamilyFinding::with_fingerprints(family, &fingerprints);
756 assert_eq!(finding.actions.len(), 2);
757 assert_eq!(
758 finding.actions[0].kind,
759 CloneFamilyActionType::ExtractShared
760 );
761 assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
762 }
763
764 #[test]
765 fn payload_from_report_wraps_all_findings() {
766 let report = DuplicationReport {
767 clone_groups: vec![group(2), group(3)],
768 clone_families: vec![CloneFamily {
769 files: vec![PathBuf::from("/root/a.ts")],
770 groups: vec![group(2)],
771 total_duplicated_lines: 20,
772 total_duplicated_tokens: 100,
773 suggestions: Vec::new(),
774 }],
775 mirrored_directories: Vec::new(),
776 stats: DuplicationStats::default(),
777 };
778 let payload = DupesReportPayload::from_report(&report);
779 assert_eq!(payload.clone_groups.len(), 2);
780 assert_eq!(payload.clone_families.len(), 1);
781 for finding in &payload.clone_groups {
782 assert_eq!(finding.actions.len(), 2);
783 }
784 assert_eq!(payload.clone_families[0].actions.len(), 2);
785 }
786
787 #[test]
788 fn duplication_codeclimate_uses_relative_normalized_paths() {
789 let report = DuplicationReport {
790 clone_groups: vec![CloneGroup {
791 instances: vec![CloneInstance {
792 is_symlink: false,
793 file: PathBuf::from("/root/app/[id]/page.tsx"),
794 start_line: 4,
795 end_line: 8,
796 start_col: 0,
797 end_col: 0,
798 fragment: "const duplicate = 1;".to_string(),
799 }],
800 token_count: 42,
801 line_count: 5,
802 similarity: None,
803 }],
804 clone_families: Vec::new(),
805 mirrored_directories: Vec::new(),
806 stats: DuplicationStats::default(),
807 };
808
809 let issues = build_duplication_codeclimate(&report, Path::new("/root"));
810
811 assert_eq!(issues.len(), 1);
812 let issue = &issues[0];
813 assert_eq!(issue.check_name, "fallow/code-duplication");
814 assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
815 assert_eq!(issue.location.lines.begin, 4);
816 assert_eq!(issue.categories, vec!["Duplication"]);
817 assert!(issue.description.starts_with("Code clone dup:"));
818 assert_eq!(issue.location.lines.end, Some(8));
819 }
820}