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
151impl DuplicationGroup {
152 pub fn strip_fragments(&mut self) {
155 for finding in &mut self.clone_groups {
156 for instance in &mut finding.group.instances {
157 instance.instance.fragment.clear();
158 }
159 }
160 for family in &mut self.clone_families {
161 for finding in &mut family.groups {
162 finding.group.strip_fragments();
163 }
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize)]
170pub struct DuplicationGrouping {
171 pub mode: &'static str,
173 pub groups: Vec<DuplicationGroup>,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185#[serde(rename_all = "kebab-case")]
186pub enum CloneDemotionReason {
187 NoAddedLines,
191}
192
193impl CloneDemotionReason {
194 #[must_use]
197 pub fn wire_name(self) -> String {
198 match serde_json::to_value(self) {
199 Ok(serde_json::Value::String(name)) => name,
200 _ => String::new(),
201 }
202 }
203}
204
205#[derive(Debug, Clone, Serialize)]
211#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
212pub struct CloneGroupFinding {
213 #[serde(flatten)]
215 pub group: CloneGroup,
216 pub fingerprint: String,
221 pub spread: usize,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub suggested_name: Option<String>,
231 pub actions: Vec<CloneGroupAction>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub introduced: Option<AuditIntroduced>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub demotion_reason: Option<CloneDemotionReason>,
245}
246
247impl CloneGroupFinding {
248 #[allow(
250 dead_code,
251 reason = "kept for focused wrapper tests and non-report construction paths"
252 )]
253 #[must_use]
254 pub fn with_actions(group: CloneGroup) -> Self {
255 let fingerprint = clone_fingerprint(&group.instances);
256 Self::with_fingerprint(group, fingerprint)
257 }
258
259 #[must_use]
261 pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
262 let spread = group.spread();
263 let suggested_name = dominant_identifier(&group);
264 let actions = clone_group_actions(group.line_count, group.instances.len());
265 Self {
266 fingerprint,
267 spread,
268 suggested_name,
269 group,
270 actions,
271 introduced: None,
272 demotion_reason: None,
273 }
274 }
275}
276
277#[derive(Debug, Clone, Serialize)]
288#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
289pub struct CloneFamilyFinding {
290 #[serde(serialize_with = "serde_path::serialize_vec")]
292 pub files: Vec<PathBuf>,
293 pub groups: Vec<CloneGroupFinding>,
297 pub total_duplicated_lines: usize,
299 pub total_duplicated_tokens: usize,
301 pub suggestions: Vec<RefactoringSuggestion>,
303 pub actions: Vec<CloneFamilyAction>,
308}
309
310impl CloneFamilyFinding {
311 #[allow(
313 dead_code,
314 reason = "kept for focused wrapper tests and non-report construction paths"
315 )]
316 #[must_use]
317 pub fn with_actions(family: CloneFamily) -> Self {
318 let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
319 Self::with_fingerprints(family, &fingerprints)
320 }
321
322 #[must_use]
325 pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
326 let actions = build_clone_family_actions(
327 &family.groups,
328 family.total_duplicated_lines,
329 &family.suggestions,
330 );
331 Self {
332 files: family.files,
333 groups: family
334 .groups
335 .into_iter()
336 .map(|group| {
337 let fingerprint = fingerprints.fingerprint_for_group(&group);
338 CloneGroupFinding::with_fingerprint(group, fingerprint)
339 })
340 .collect(),
341 total_duplicated_lines: family.total_duplicated_lines,
342 total_duplicated_tokens: family.total_duplicated_tokens,
343 suggestions: family.suggestions,
344 actions,
345 }
346 }
347}
348
349fn build_clone_family_actions(
350 groups: &[CloneGroup],
351 total_duplicated_lines: usize,
352 suggestions: &[RefactoringSuggestion],
353) -> Vec<CloneFamilyAction> {
354 clone_family_actions(
355 groups.len(),
356 total_duplicated_lines,
357 suggestions
358 .iter()
359 .map(|suggestion| suggestion.description.as_str()),
360 )
361}
362
363#[derive(Debug, Clone, Serialize)]
373#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
374pub struct DupesReportPayload {
375 pub clone_groups: Vec<CloneGroupFinding>,
377 pub clone_families: Vec<CloneFamilyFinding>,
385 #[serde(default, skip_serializing_if = "Vec::is_empty")]
387 pub mirrored_directories: Vec<MirroredDirectory>,
388 pub stats: DuplicationStats,
390}
391
392impl DupesReportPayload {
393 #[must_use]
395 pub fn from_report(report: &DuplicationReport) -> Self {
396 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
397 Self {
398 clone_groups: report
399 .clone_groups
400 .iter()
401 .map(|group| {
402 CloneGroupFinding::with_fingerprint(
403 group.clone(),
404 fingerprints.fingerprint_for_group(group),
405 )
406 })
407 .collect(),
408 clone_families: report
409 .clone_families
410 .iter()
411 .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
412 .collect(),
413 mirrored_directories: report.mirrored_directories.clone(),
414 stats: report.stats.clone(),
415 }
416 }
417
418 #[must_use]
425 pub fn from_report_with_fragments(report: &DuplicationReport, include_fragments: bool) -> Self {
426 let mut payload = Self::from_report(report);
427 if !include_fragments {
428 payload.strip_fragments();
429 }
430 payload
431 }
432
433 pub fn strip_fragments(&mut self) {
436 for finding in &mut self.clone_groups {
437 finding.group.strip_fragments();
438 }
439 for family in &mut self.clone_families {
440 for finding in &mut family.groups {
441 finding.group.strip_fragments();
442 }
443 }
444 }
445}
446
447#[must_use]
453#[expect(
454 clippy::cast_possible_truncation,
455 reason = "line numbers are bounded by source size"
456)]
457pub fn build_duplication_codeclimate(
458 report: &DuplicationReport,
459 root: &Path,
460) -> Vec<CodeClimateIssue> {
461 let mut issues = Vec::new();
462 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
463
464 for group in &report.clone_groups {
465 let clone_fingerprint = fingerprints.fingerprint_for_group(group);
466 let token_str = group.token_count.to_string();
467 let line_count_str = group.line_count.to_string();
468 let fragment_prefix: String = group
469 .instances
470 .first()
471 .map(|inst| inst.fragment.chars().take(64).collect())
472 .unwrap_or_default();
473
474 for (instance_index, instance) in group.instances.iter().enumerate() {
475 let path = codeclimate_path(&instance.file, root);
476 let start_str = instance.start_line.to_string();
477 let fp = codeclimate_fingerprint_hash(&[
478 "fallow/code-duplication",
479 &path,
480 &start_str,
481 &token_str,
482 &line_count_str,
483 &fragment_prefix,
484 ]);
485 let mut issue = fallow_output::build_codeclimate_issue(CodeClimateIssueInput {
486 check_name: "fallow/code-duplication",
487 description: &format!(
488 "Code clone {clone_fingerprint} ({} lines, {} instances)",
489 group.line_count,
490 group.instances.len()
491 ),
492 severity: CodeClimateSeverity::Minor,
493 category: "Duplication",
494 path: &path,
495 begin_line: Some(instance.start_line as u32),
496 fingerprint: &fp,
497 });
498 issue.location.lines.end = Some(instance.end_line as u32);
499 issue.other_locations = group
500 .instances
501 .iter()
502 .enumerate()
503 .filter(|(peer_index, _)| *peer_index != instance_index)
504 .map(|(_, peer)| fallow_output::CodeClimateLocation {
505 path: codeclimate_path(&peer.file, root),
506 lines: fallow_output::CodeClimateLines {
507 begin: peer.start_line as u32,
508 end: Some(peer.end_line as u32),
509 },
510 })
511 .collect();
512 issue.other_locations.sort_by(|a, b| {
513 (&a.path, a.lines.begin, a.lines.end).cmp(&(&b.path, b.lines.begin, b.lines.end))
514 });
515 issues.push(issue);
516 }
517 }
518
519 issues
520}
521
522fn codeclimate_path(path: &Path, root: &Path) -> String {
523 normalize_uri(
524 &path
525 .strip_prefix(root)
526 .unwrap_or(path)
527 .display()
528 .to_string(),
529 )
530}
531
532#[cfg(test)]
533mod tests {
534 use std::path::Path;
535
536 use fallow_output::{CloneFamilyActionType, CloneGroupActionType};
537 use fallow_types::duplicates::{
538 CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
539 };
540
541 use super::*;
542
543 fn instance(path: &str) -> CloneInstance {
544 CloneInstance {
545 file: PathBuf::from(path),
546 start_line: 1,
547 end_line: 10,
548 start_col: 0,
549 end_col: 0,
550 fragment: String::new(),
551 }
552 }
553
554 fn group(instances: usize) -> CloneGroup {
555 CloneGroup {
556 instances: (0..instances)
557 .map(|i| instance(&format!("/root/file_{i}.ts")))
558 .collect(),
559 token_count: 100,
560 line_count: 20,
561 similarity: None,
562 }
563 }
564
565 #[test]
566 fn clone_group_finding_position_0_is_extract_shared() {
567 let finding = CloneGroupFinding::with_actions(group(2));
568 assert_eq!(finding.actions.len(), 2);
569 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
570 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
571 assert!(finding.introduced.is_none());
572 assert!(finding.demotion_reason.is_none());
573 }
574
575 #[test]
576 fn clone_group_finding_omits_audit_only_fields_outside_audit() {
577 let finding = CloneGroupFinding::with_actions(group(2));
580 let value = serde_json::to_value(&finding).expect("finding serializes");
581 assert!(value.get("introduced").is_none());
582 assert!(value.get("demotion_reason").is_none());
583 }
584
585 #[test]
586 fn suppressed_fragments_leave_locations_and_fingerprints_intact() {
587 let mut with_text = group(2);
588 for (index, instance) in with_text.instances.iter_mut().enumerate() {
589 instance.fragment = format!("const shared{index} = compute(input);");
590 }
591 let family = CloneFamily {
592 files: vec![PathBuf::from("/root/file_0.ts")],
593 groups: vec![with_text.clone()],
594 total_duplicated_lines: 20,
595 total_duplicated_tokens: 100,
596 suggestions: Vec::new(),
597 };
598 let report = DuplicationReport {
599 clone_groups: vec![with_text],
600 clone_families: vec![family],
601 mirrored_directories: Vec::new(),
602 stats: DuplicationStats::default(),
603 };
604
605 let kept = DupesReportPayload::from_report_with_fragments(&report, true);
606 let dropped = DupesReportPayload::from_report_with_fragments(&report, false);
607
608 assert_eq!(
609 kept.clone_groups[0].fingerprint, dropped.clone_groups[0].fingerprint,
610 "fingerprints are computed before the text is dropped"
611 );
612
613 let kept_value = serde_json::to_value(&kept).expect("payload serializes");
614 assert!(kept_value["clone_groups"][0]["instances"][0]["fragment"].is_string());
615
616 let value = serde_json::to_value(&dropped).expect("payload serializes");
617 let instance = &value["clone_groups"][0]["instances"][0];
618 assert!(
619 instance.get("fragment").is_none(),
620 "the verbatim source text must be absent, not empty"
621 );
622 assert!(instance.get("file").is_some());
623 assert!(instance.get("start_line").is_some());
624 assert!(instance.get("end_col").is_some());
625 assert!(
626 value["clone_families"][0]["groups"][0]["instances"][0]
627 .get("fragment")
628 .is_none(),
629 "nested clone-family copies carry the same text and must be dropped too"
630 );
631 }
632
633 #[test]
634 fn clone_demotion_reason_wire_name_matches_serde_representation() {
635 let reason = CloneDemotionReason::NoAddedLines;
636 assert_eq!(
637 serde_json::to_value(reason).expect("reason serializes"),
638 serde_json::Value::String(reason.wire_name())
639 );
640 assert_eq!(reason.wire_name(), "no-added-lines");
641 }
642
643 #[test]
644 fn attributed_clone_group_finding_actions_match_clone_group_shape() {
645 let attributed = AttributedCloneGroup {
646 primary_owner: "src".to_string(),
647 token_count: 100,
648 line_count: 20,
649 similarity: None,
650 instances: vec![
651 AttributedInstance {
652 instance: instance("/root/src/a.ts"),
653 owner: "src".to_string(),
654 },
655 AttributedInstance {
656 instance: instance("/root/src/b.ts"),
657 owner: "src".to_string(),
658 },
659 ],
660 };
661 let finding = AttributedCloneGroupFinding::with_actions(attributed);
662 assert_eq!(finding.actions.len(), 2);
663 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
664 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
665 }
666
667 #[test]
668 fn clone_group_finding_surfaces_dominant_identifier() {
669 let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
670 let g = CloneGroup {
671 instances: vec![
672 CloneInstance {
673 file: PathBuf::from("/root/a.ts"),
674 start_line: 1,
675 end_line: 3,
676 start_col: 0,
677 end_col: 0,
678 fragment: fragment.to_string(),
679 },
680 CloneInstance {
681 file: PathBuf::from("/root/b.ts"),
682 start_line: 1,
683 end_line: 3,
684 start_col: 0,
685 end_col: 0,
686 fragment: fragment.to_string(),
687 },
688 ],
689 token_count: 100,
690 line_count: 3,
691 similarity: None,
692 };
693 let finding = CloneGroupFinding::with_actions(g);
694 assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
695 }
696
697 #[test]
698 fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
699 let finding = CloneGroupFinding::with_actions(group(2));
700 assert!(finding.suggested_name.is_none());
701 }
702
703 #[test]
704 fn clone_group_finding_description_pluralises_instance_count() {
705 let single = CloneGroupFinding::with_actions(group(1));
706 assert!(single.actions[0].description.contains("1 instance"));
707 assert!(!single.actions[0].description.contains("1 instances"));
708 let multi = CloneGroupFinding::with_actions(group(3));
709 assert!(multi.actions[0].description.contains("3 instances"));
710 }
711
712 #[test]
713 fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
714 let family = CloneFamily {
715 files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
716 groups: vec![group(2), group(2)],
717 total_duplicated_lines: 40,
718 total_duplicated_tokens: 200,
719 suggestions: vec![
720 RefactoringSuggestion {
721 kind: RefactoringKind::ExtractFunction,
722 description: "Extract helper".to_string(),
723 estimated_savings: 10,
724 },
725 RefactoringSuggestion {
726 kind: RefactoringKind::ExtractModule,
727 description: "Extract module".to_string(),
728 estimated_savings: 30,
729 },
730 ],
731 };
732 let finding = CloneFamilyFinding::with_actions(family);
733 assert_eq!(finding.actions.len(), 4);
734 assert_eq!(
735 finding.actions[0].kind,
736 CloneFamilyActionType::ExtractShared
737 );
738 assert_eq!(
739 finding.actions[1].kind,
740 CloneFamilyActionType::ApplySuggestion
741 );
742 assert_eq!(finding.actions[1].description, "Extract helper");
743 assert_eq!(
744 finding.actions[2].kind,
745 CloneFamilyActionType::ApplySuggestion
746 );
747 assert_eq!(finding.actions[2].description, "Extract module");
748 assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
749 assert_eq!(finding.groups.len(), 2);
750 for inner in &finding.groups {
751 assert_eq!(inner.actions.len(), 2);
752 assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
753 assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
754 }
755 }
756
757 #[test]
758 fn clone_family_finding_with_no_suggestions_emits_two_actions() {
759 let family = CloneFamily {
760 files: vec![PathBuf::from("/root/a.ts")],
761 groups: vec![group(2)],
762 total_duplicated_lines: 20,
763 total_duplicated_tokens: 100,
764 suggestions: Vec::new(),
765 };
766 let finding = CloneFamilyFinding::with_actions(family);
767 assert_eq!(finding.actions.len(), 2);
768 assert_eq!(
769 finding.actions[0].kind,
770 CloneFamilyActionType::ExtractShared
771 );
772 assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
773 }
774
775 #[test]
776 fn payload_from_report_wraps_all_findings() {
777 let report = DuplicationReport {
778 clone_groups: vec![group(2), group(3)],
779 clone_families: vec![CloneFamily {
780 files: vec![PathBuf::from("/root/a.ts")],
781 groups: vec![group(2)],
782 total_duplicated_lines: 20,
783 total_duplicated_tokens: 100,
784 suggestions: Vec::new(),
785 }],
786 mirrored_directories: Vec::new(),
787 stats: DuplicationStats::default(),
788 };
789 let payload = DupesReportPayload::from_report(&report);
790 assert_eq!(payload.clone_groups.len(), 2);
791 assert_eq!(payload.clone_families.len(), 1);
792 for finding in &payload.clone_groups {
793 assert_eq!(finding.actions.len(), 2);
794 }
795 assert_eq!(payload.clone_families[0].actions.len(), 2);
796 }
797
798 #[test]
799 fn duplication_codeclimate_uses_relative_normalized_paths() {
800 let report = DuplicationReport {
801 clone_groups: vec![CloneGroup {
802 instances: vec![CloneInstance {
803 file: PathBuf::from("/root/app/[id]/page.tsx"),
804 start_line: 4,
805 end_line: 8,
806 start_col: 0,
807 end_col: 0,
808 fragment: "const duplicate = 1;".to_string(),
809 }],
810 token_count: 42,
811 line_count: 5,
812 similarity: None,
813 }],
814 clone_families: Vec::new(),
815 mirrored_directories: Vec::new(),
816 stats: DuplicationStats::default(),
817 };
818
819 let issues = build_duplication_codeclimate(&report, Path::new("/root"));
820
821 assert_eq!(issues.len(), 1);
822 let issue = &issues[0];
823 assert_eq!(issue.check_name, "fallow/code-duplication");
824 assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
825 assert_eq!(issue.location.lines.begin, 4);
826 assert_eq!(issue.categories, vec!["Duplication"]);
827 assert!(issue.description.starts_with("Code clone dup:"));
828 assert_eq!(issue.location.lines.end, Some(8));
829 }
830}