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 file: PathBuf::from(path),
529 start_line: 1,
530 end_line: 10,
531 start_col: 0,
532 end_col: 0,
533 fragment: String::new(),
534 }
535 }
536
537 fn group(instances: usize) -> CloneGroup {
538 CloneGroup {
539 instances: (0..instances)
540 .map(|i| instance(&format!("/root/file_{i}.ts")))
541 .collect(),
542 token_count: 100,
543 line_count: 20,
544 similarity: None,
545 }
546 }
547
548 #[test]
549 fn clone_group_finding_position_0_is_extract_shared() {
550 let finding = CloneGroupFinding::with_fingerprint(group(2), TEST_FINGERPRINT.to_string());
551 assert_eq!(finding.actions.len(), 2);
552 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
553 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
554 assert!(finding.introduced.is_none());
555 assert!(finding.demotion_reason.is_none());
556 }
557
558 #[test]
559 fn clone_group_finding_omits_audit_only_fields_outside_audit() {
560 let finding = CloneGroupFinding::with_fingerprint(group(2), TEST_FINGERPRINT.to_string());
563 let value = serde_json::to_value(&finding).expect("finding serializes");
564 assert!(value.get("introduced").is_none());
565 assert!(value.get("demotion_reason").is_none());
566 }
567
568 #[test]
569 fn suppressed_fragments_leave_locations_and_fingerprints_intact() {
570 let mut with_text = group(2);
571 for (index, instance) in with_text.instances.iter_mut().enumerate() {
572 instance.fragment = format!("const shared{index} = compute(input);");
573 }
574 let family = CloneFamily {
575 files: vec![PathBuf::from("/root/file_0.ts")],
576 groups: vec![with_text.clone()],
577 total_duplicated_lines: 20,
578 total_duplicated_tokens: 100,
579 suggestions: Vec::new(),
580 };
581 let report = DuplicationReport {
582 clone_groups: vec![with_text],
583 clone_families: vec![family],
584 mirrored_directories: Vec::new(),
585 stats: DuplicationStats::default(),
586 };
587
588 let kept = DupesReportPayload::from_report_with_fragments(&report, true);
589 let dropped = DupesReportPayload::from_report_with_fragments(&report, false);
590
591 assert_eq!(
592 kept.clone_groups[0].fingerprint, dropped.clone_groups[0].fingerprint,
593 "fingerprints are computed before the text is dropped"
594 );
595
596 let kept_value = serde_json::to_value(&kept).expect("payload serializes");
597 assert!(kept_value["clone_groups"][0]["instances"][0]["fragment"].is_string());
598
599 let value = serde_json::to_value(&dropped).expect("payload serializes");
600 let instance = &value["clone_groups"][0]["instances"][0];
601 assert!(
602 instance.get("fragment").is_none(),
603 "the verbatim source text must be absent, not empty"
604 );
605 assert!(instance.get("file").is_some());
606 assert!(instance.get("start_line").is_some());
607 assert!(instance.get("end_col").is_some());
608 assert!(
609 value["clone_families"][0]["groups"][0]["instances"][0]
610 .get("fragment")
611 .is_none(),
612 "nested clone-family copies carry the same text and must be dropped too"
613 );
614 }
615
616 #[test]
617 fn clone_demotion_reason_wire_name_matches_serde_representation() {
618 let reason = CloneDemotionReason::NoAddedLines;
619 assert_eq!(
620 serde_json::to_value(reason).expect("reason serializes"),
621 serde_json::Value::String(reason.wire_name())
622 );
623 assert_eq!(reason.wire_name(), "no-added-lines");
624 }
625
626 #[test]
627 fn attributed_clone_group_finding_actions_match_clone_group_shape() {
628 let attributed = AttributedCloneGroup {
629 primary_owner: "src".to_string(),
630 token_count: 100,
631 line_count: 20,
632 similarity: None,
633 instances: vec![
634 AttributedInstance {
635 instance: instance("/root/src/a.ts"),
636 owner: "src".to_string(),
637 },
638 AttributedInstance {
639 instance: instance("/root/src/b.ts"),
640 owner: "src".to_string(),
641 },
642 ],
643 };
644 let finding =
645 AttributedCloneGroupFinding::with_fingerprint(attributed, TEST_FINGERPRINT.to_string());
646 assert_eq!(finding.actions.len(), 2);
647 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
648 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
649 }
650
651 #[test]
652 fn clone_group_finding_surfaces_dominant_identifier() {
653 let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
654 let g = CloneGroup {
655 instances: vec![
656 CloneInstance {
657 file: PathBuf::from("/root/a.ts"),
658 start_line: 1,
659 end_line: 3,
660 start_col: 0,
661 end_col: 0,
662 fragment: fragment.to_string(),
663 },
664 CloneInstance {
665 file: PathBuf::from("/root/b.ts"),
666 start_line: 1,
667 end_line: 3,
668 start_col: 0,
669 end_col: 0,
670 fragment: fragment.to_string(),
671 },
672 ],
673 token_count: 100,
674 line_count: 3,
675 similarity: None,
676 };
677 let finding = CloneGroupFinding::with_fingerprint(g, TEST_FINGERPRINT.to_string());
678 assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
679 }
680
681 #[test]
682 fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
683 let finding = CloneGroupFinding::with_fingerprint(group(2), TEST_FINGERPRINT.to_string());
684 assert!(finding.suggested_name.is_none());
685 }
686
687 #[test]
688 fn clone_group_finding_description_pluralises_instance_count() {
689 let single = CloneGroupFinding::with_fingerprint(group(1), TEST_FINGERPRINT.to_string());
690 assert!(single.actions[0].description.contains("1 instance"));
691 assert!(!single.actions[0].description.contains("1 instances"));
692 let multi = CloneGroupFinding::with_fingerprint(group(3), TEST_FINGERPRINT.to_string());
693 assert!(multi.actions[0].description.contains("3 instances"));
694 }
695
696 #[test]
697 fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
698 let family = CloneFamily {
699 files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
700 groups: vec![group(2), group(2)],
701 total_duplicated_lines: 40,
702 total_duplicated_tokens: 200,
703 suggestions: vec![
704 RefactoringSuggestion {
705 kind: RefactoringKind::ExtractFunction,
706 description: "Extract helper".to_string(),
707 estimated_savings: 10,
708 },
709 RefactoringSuggestion {
710 kind: RefactoringKind::ExtractModule,
711 description: "Extract module".to_string(),
712 estimated_savings: 30,
713 },
714 ],
715 };
716 let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
717 let finding = CloneFamilyFinding::with_fingerprints(family, &fingerprints);
718 assert_eq!(finding.actions.len(), 4);
719 assert_eq!(
720 finding.actions[0].kind,
721 CloneFamilyActionType::ExtractShared
722 );
723 assert_eq!(
724 finding.actions[1].kind,
725 CloneFamilyActionType::ApplySuggestion
726 );
727 assert_eq!(finding.actions[1].description, "Extract helper");
728 assert_eq!(
729 finding.actions[2].kind,
730 CloneFamilyActionType::ApplySuggestion
731 );
732 assert_eq!(finding.actions[2].description, "Extract module");
733 assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
734 assert_eq!(finding.groups.len(), 2);
735 for inner in &finding.groups {
736 assert_eq!(inner.actions.len(), 2);
737 assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
738 assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
739 }
740 }
741
742 #[test]
743 fn clone_family_finding_with_no_suggestions_emits_two_actions() {
744 let family = CloneFamily {
745 files: vec![PathBuf::from("/root/a.ts")],
746 groups: vec![group(2)],
747 total_duplicated_lines: 20,
748 total_duplicated_tokens: 100,
749 suggestions: Vec::new(),
750 };
751 let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
752 let finding = CloneFamilyFinding::with_fingerprints(family, &fingerprints);
753 assert_eq!(finding.actions.len(), 2);
754 assert_eq!(
755 finding.actions[0].kind,
756 CloneFamilyActionType::ExtractShared
757 );
758 assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
759 }
760
761 #[test]
762 fn payload_from_report_wraps_all_findings() {
763 let report = DuplicationReport {
764 clone_groups: vec![group(2), group(3)],
765 clone_families: vec![CloneFamily {
766 files: vec![PathBuf::from("/root/a.ts")],
767 groups: vec![group(2)],
768 total_duplicated_lines: 20,
769 total_duplicated_tokens: 100,
770 suggestions: Vec::new(),
771 }],
772 mirrored_directories: Vec::new(),
773 stats: DuplicationStats::default(),
774 };
775 let payload = DupesReportPayload::from_report(&report);
776 assert_eq!(payload.clone_groups.len(), 2);
777 assert_eq!(payload.clone_families.len(), 1);
778 for finding in &payload.clone_groups {
779 assert_eq!(finding.actions.len(), 2);
780 }
781 assert_eq!(payload.clone_families[0].actions.len(), 2);
782 }
783
784 #[test]
785 fn duplication_codeclimate_uses_relative_normalized_paths() {
786 let report = DuplicationReport {
787 clone_groups: vec![CloneGroup {
788 instances: vec![CloneInstance {
789 file: PathBuf::from("/root/app/[id]/page.tsx"),
790 start_line: 4,
791 end_line: 8,
792 start_col: 0,
793 end_col: 0,
794 fragment: "const duplicate = 1;".to_string(),
795 }],
796 token_count: 42,
797 line_count: 5,
798 similarity: None,
799 }],
800 clone_families: Vec::new(),
801 mirrored_directories: Vec::new(),
802 stats: DuplicationStats::default(),
803 };
804
805 let issues = build_duplication_codeclimate(&report, Path::new("/root"));
806
807 assert_eq!(issues.len(), 1);
808 let issue = &issues[0];
809 assert_eq!(issue.check_name, "fallow/code-duplication");
810 assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
811 assert_eq!(issue.location.lines.begin, 4);
812 assert_eq!(issue.categories, vec!["Duplication"]);
813 assert!(issue.description.starts_with("Code clone dup:"));
814 assert_eq!(issue.location.lines.end, Some(8));
815 }
816}