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, Copy, PartialEq, Eq, Serialize)]
167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
168#[serde(rename_all = "kebab-case")]
169pub enum CloneDemotionReason {
170 NoAddedLines,
174}
175
176impl CloneDemotionReason {
177 #[must_use]
180 pub fn wire_name(self) -> String {
181 match serde_json::to_value(self) {
182 Ok(serde_json::Value::String(name)) => name,
183 _ => String::new(),
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize)]
194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
195pub struct CloneGroupFinding {
196 #[serde(flatten)]
198 pub group: CloneGroup,
199 pub fingerprint: String,
204 pub spread: usize,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub suggested_name: Option<String>,
214 pub actions: Vec<CloneGroupAction>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub introduced: Option<AuditIntroduced>,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub demotion_reason: Option<CloneDemotionReason>,
228}
229
230impl CloneGroupFinding {
231 #[allow(
233 dead_code,
234 reason = "kept for focused wrapper tests and non-report construction paths"
235 )]
236 #[must_use]
237 pub fn with_actions(group: CloneGroup) -> Self {
238 let fingerprint = clone_fingerprint(&group.instances);
239 Self::with_fingerprint(group, fingerprint)
240 }
241
242 #[must_use]
244 pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
245 let spread = group.spread();
246 let suggested_name = dominant_identifier(&group);
247 let actions = clone_group_actions(group.line_count, group.instances.len());
248 Self {
249 fingerprint,
250 spread,
251 suggested_name,
252 group,
253 actions,
254 introduced: None,
255 demotion_reason: None,
256 }
257 }
258}
259
260#[derive(Debug, Clone, Serialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272pub struct CloneFamilyFinding {
273 #[serde(serialize_with = "serde_path::serialize_vec")]
275 pub files: Vec<PathBuf>,
276 pub groups: Vec<CloneGroupFinding>,
280 pub total_duplicated_lines: usize,
282 pub total_duplicated_tokens: usize,
284 pub suggestions: Vec<RefactoringSuggestion>,
286 pub actions: Vec<CloneFamilyAction>,
291}
292
293impl CloneFamilyFinding {
294 #[allow(
296 dead_code,
297 reason = "kept for focused wrapper tests and non-report construction paths"
298 )]
299 #[must_use]
300 pub fn with_actions(family: CloneFamily) -> Self {
301 let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
302 Self::with_fingerprints(family, &fingerprints)
303 }
304
305 #[must_use]
308 pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
309 let actions = build_clone_family_actions(
310 &family.groups,
311 family.total_duplicated_lines,
312 &family.suggestions,
313 );
314 Self {
315 files: family.files,
316 groups: family
317 .groups
318 .into_iter()
319 .map(|group| {
320 let fingerprint = fingerprints.fingerprint_for_group(&group);
321 CloneGroupFinding::with_fingerprint(group, fingerprint)
322 })
323 .collect(),
324 total_duplicated_lines: family.total_duplicated_lines,
325 total_duplicated_tokens: family.total_duplicated_tokens,
326 suggestions: family.suggestions,
327 actions,
328 }
329 }
330}
331
332fn build_clone_family_actions(
333 groups: &[CloneGroup],
334 total_duplicated_lines: usize,
335 suggestions: &[RefactoringSuggestion],
336) -> Vec<CloneFamilyAction> {
337 clone_family_actions(
338 groups.len(),
339 total_duplicated_lines,
340 suggestions
341 .iter()
342 .map(|suggestion| suggestion.description.as_str()),
343 )
344}
345
346#[derive(Debug, Clone, Serialize)]
356#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
357pub struct DupesReportPayload {
358 pub clone_groups: Vec<CloneGroupFinding>,
360 pub clone_families: Vec<CloneFamilyFinding>,
368 #[serde(default, skip_serializing_if = "Vec::is_empty")]
370 pub mirrored_directories: Vec<MirroredDirectory>,
371 pub stats: DuplicationStats,
373}
374
375impl DupesReportPayload {
376 #[must_use]
378 pub fn from_report(report: &DuplicationReport) -> Self {
379 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
380 Self {
381 clone_groups: report
382 .clone_groups
383 .iter()
384 .map(|group| {
385 CloneGroupFinding::with_fingerprint(
386 group.clone(),
387 fingerprints.fingerprint_for_group(group),
388 )
389 })
390 .collect(),
391 clone_families: report
392 .clone_families
393 .iter()
394 .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
395 .collect(),
396 mirrored_directories: report.mirrored_directories.clone(),
397 stats: report.stats.clone(),
398 }
399 }
400}
401
402#[must_use]
408#[expect(
409 clippy::cast_possible_truncation,
410 reason = "line numbers are bounded by source size"
411)]
412pub fn build_duplication_codeclimate(
413 report: &DuplicationReport,
414 root: &Path,
415) -> Vec<CodeClimateIssue> {
416 let mut issues = Vec::new();
417 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
418
419 for group in &report.clone_groups {
420 let clone_fingerprint = fingerprints.fingerprint_for_group(group);
421 let token_str = group.token_count.to_string();
422 let line_count_str = group.line_count.to_string();
423 let fragment_prefix: String = group
424 .instances
425 .first()
426 .map(|inst| inst.fragment.chars().take(64).collect())
427 .unwrap_or_default();
428
429 for (instance_index, instance) in group.instances.iter().enumerate() {
430 let path = codeclimate_path(&instance.file, root);
431 let start_str = instance.start_line.to_string();
432 let fp = codeclimate_fingerprint_hash(&[
433 "fallow/code-duplication",
434 &path,
435 &start_str,
436 &token_str,
437 &line_count_str,
438 &fragment_prefix,
439 ]);
440 let mut issue = fallow_output::build_codeclimate_issue(CodeClimateIssueInput {
441 check_name: "fallow/code-duplication",
442 description: &format!(
443 "Code clone {clone_fingerprint} ({} lines, {} instances)",
444 group.line_count,
445 group.instances.len()
446 ),
447 severity: CodeClimateSeverity::Minor,
448 category: "Duplication",
449 path: &path,
450 begin_line: Some(instance.start_line as u32),
451 fingerprint: &fp,
452 });
453 issue.location.lines.end = Some(instance.end_line as u32);
454 issue.other_locations = group
455 .instances
456 .iter()
457 .enumerate()
458 .filter(|(peer_index, _)| *peer_index != instance_index)
459 .map(|(_, peer)| fallow_output::CodeClimateLocation {
460 path: codeclimate_path(&peer.file, root),
461 lines: fallow_output::CodeClimateLines {
462 begin: peer.start_line as u32,
463 end: Some(peer.end_line as u32),
464 },
465 })
466 .collect();
467 issue.other_locations.sort_by(|a, b| {
468 (&a.path, a.lines.begin, a.lines.end).cmp(&(&b.path, b.lines.begin, b.lines.end))
469 });
470 issues.push(issue);
471 }
472 }
473
474 issues
475}
476
477fn codeclimate_path(path: &Path, root: &Path) -> String {
478 normalize_uri(
479 &path
480 .strip_prefix(root)
481 .unwrap_or(path)
482 .display()
483 .to_string(),
484 )
485}
486
487#[cfg(test)]
488mod tests {
489 use std::path::Path;
490
491 use fallow_output::{CloneFamilyActionType, CloneGroupActionType};
492 use fallow_types::duplicates::{
493 CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
494 };
495
496 use super::*;
497
498 fn instance(path: &str) -> CloneInstance {
499 CloneInstance {
500 file: PathBuf::from(path),
501 start_line: 1,
502 end_line: 10,
503 start_col: 0,
504 end_col: 0,
505 fragment: String::new(),
506 }
507 }
508
509 fn group(instances: usize) -> CloneGroup {
510 CloneGroup {
511 instances: (0..instances)
512 .map(|i| instance(&format!("/root/file_{i}.ts")))
513 .collect(),
514 token_count: 100,
515 line_count: 20,
516 similarity: None,
517 }
518 }
519
520 #[test]
521 fn clone_group_finding_position_0_is_extract_shared() {
522 let finding = CloneGroupFinding::with_actions(group(2));
523 assert_eq!(finding.actions.len(), 2);
524 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
525 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
526 assert!(finding.introduced.is_none());
527 assert!(finding.demotion_reason.is_none());
528 }
529
530 #[test]
531 fn clone_group_finding_omits_audit_only_fields_outside_audit() {
532 let finding = CloneGroupFinding::with_actions(group(2));
535 let value = serde_json::to_value(&finding).expect("finding serializes");
536 assert!(value.get("introduced").is_none());
537 assert!(value.get("demotion_reason").is_none());
538 }
539
540 #[test]
541 fn clone_demotion_reason_wire_name_matches_serde_representation() {
542 let reason = CloneDemotionReason::NoAddedLines;
543 assert_eq!(
544 serde_json::to_value(reason).expect("reason serializes"),
545 serde_json::Value::String(reason.wire_name())
546 );
547 assert_eq!(reason.wire_name(), "no-added-lines");
548 }
549
550 #[test]
551 fn attributed_clone_group_finding_actions_match_clone_group_shape() {
552 let attributed = AttributedCloneGroup {
553 primary_owner: "src".to_string(),
554 token_count: 100,
555 line_count: 20,
556 similarity: None,
557 instances: vec![
558 AttributedInstance {
559 instance: instance("/root/src/a.ts"),
560 owner: "src".to_string(),
561 },
562 AttributedInstance {
563 instance: instance("/root/src/b.ts"),
564 owner: "src".to_string(),
565 },
566 ],
567 };
568 let finding = AttributedCloneGroupFinding::with_actions(attributed);
569 assert_eq!(finding.actions.len(), 2);
570 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
571 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
572 }
573
574 #[test]
575 fn clone_group_finding_surfaces_dominant_identifier() {
576 let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
577 let g = CloneGroup {
578 instances: vec![
579 CloneInstance {
580 file: PathBuf::from("/root/a.ts"),
581 start_line: 1,
582 end_line: 3,
583 start_col: 0,
584 end_col: 0,
585 fragment: fragment.to_string(),
586 },
587 CloneInstance {
588 file: PathBuf::from("/root/b.ts"),
589 start_line: 1,
590 end_line: 3,
591 start_col: 0,
592 end_col: 0,
593 fragment: fragment.to_string(),
594 },
595 ],
596 token_count: 100,
597 line_count: 3,
598 similarity: None,
599 };
600 let finding = CloneGroupFinding::with_actions(g);
601 assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
602 }
603
604 #[test]
605 fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
606 let finding = CloneGroupFinding::with_actions(group(2));
607 assert!(finding.suggested_name.is_none());
608 }
609
610 #[test]
611 fn clone_group_finding_description_pluralises_instance_count() {
612 let single = CloneGroupFinding::with_actions(group(1));
613 assert!(single.actions[0].description.contains("1 instance"));
614 assert!(!single.actions[0].description.contains("1 instances"));
615 let multi = CloneGroupFinding::with_actions(group(3));
616 assert!(multi.actions[0].description.contains("3 instances"));
617 }
618
619 #[test]
620 fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
621 let family = CloneFamily {
622 files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
623 groups: vec![group(2), group(2)],
624 total_duplicated_lines: 40,
625 total_duplicated_tokens: 200,
626 suggestions: vec![
627 RefactoringSuggestion {
628 kind: RefactoringKind::ExtractFunction,
629 description: "Extract helper".to_string(),
630 estimated_savings: 10,
631 },
632 RefactoringSuggestion {
633 kind: RefactoringKind::ExtractModule,
634 description: "Extract module".to_string(),
635 estimated_savings: 30,
636 },
637 ],
638 };
639 let finding = CloneFamilyFinding::with_actions(family);
640 assert_eq!(finding.actions.len(), 4);
641 assert_eq!(
642 finding.actions[0].kind,
643 CloneFamilyActionType::ExtractShared
644 );
645 assert_eq!(
646 finding.actions[1].kind,
647 CloneFamilyActionType::ApplySuggestion
648 );
649 assert_eq!(finding.actions[1].description, "Extract helper");
650 assert_eq!(
651 finding.actions[2].kind,
652 CloneFamilyActionType::ApplySuggestion
653 );
654 assert_eq!(finding.actions[2].description, "Extract module");
655 assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
656 assert_eq!(finding.groups.len(), 2);
657 for inner in &finding.groups {
658 assert_eq!(inner.actions.len(), 2);
659 assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
660 assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
661 }
662 }
663
664 #[test]
665 fn clone_family_finding_with_no_suggestions_emits_two_actions() {
666 let family = CloneFamily {
667 files: vec![PathBuf::from("/root/a.ts")],
668 groups: vec![group(2)],
669 total_duplicated_lines: 20,
670 total_duplicated_tokens: 100,
671 suggestions: Vec::new(),
672 };
673 let finding = CloneFamilyFinding::with_actions(family);
674 assert_eq!(finding.actions.len(), 2);
675 assert_eq!(
676 finding.actions[0].kind,
677 CloneFamilyActionType::ExtractShared
678 );
679 assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
680 }
681
682 #[test]
683 fn payload_from_report_wraps_all_findings() {
684 let report = DuplicationReport {
685 clone_groups: vec![group(2), group(3)],
686 clone_families: vec![CloneFamily {
687 files: vec![PathBuf::from("/root/a.ts")],
688 groups: vec![group(2)],
689 total_duplicated_lines: 20,
690 total_duplicated_tokens: 100,
691 suggestions: Vec::new(),
692 }],
693 mirrored_directories: Vec::new(),
694 stats: DuplicationStats::default(),
695 };
696 let payload = DupesReportPayload::from_report(&report);
697 assert_eq!(payload.clone_groups.len(), 2);
698 assert_eq!(payload.clone_families.len(), 1);
699 for finding in &payload.clone_groups {
700 assert_eq!(finding.actions.len(), 2);
701 }
702 assert_eq!(payload.clone_families[0].actions.len(), 2);
703 }
704
705 #[test]
706 fn duplication_codeclimate_uses_relative_normalized_paths() {
707 let report = DuplicationReport {
708 clone_groups: vec![CloneGroup {
709 instances: vec![CloneInstance {
710 file: PathBuf::from("/root/app/[id]/page.tsx"),
711 start_line: 4,
712 end_line: 8,
713 start_col: 0,
714 end_col: 0,
715 fragment: "const duplicate = 1;".to_string(),
716 }],
717 token_count: 42,
718 line_count: 5,
719 similarity: None,
720 }],
721 clone_families: Vec::new(),
722 mirrored_directories: Vec::new(),
723 stats: DuplicationStats::default(),
724 };
725
726 let issues = build_duplication_codeclimate(&report, Path::new("/root"));
727
728 assert_eq!(issues.len(), 1);
729 let issue = &issues[0];
730 assert_eq!(issue.check_name, "fallow/code-duplication");
731 assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
732 assert_eq!(issue.location.lines.begin, 4);
733 assert_eq!(issue.categories, vec!["Duplication"]);
734 assert!(issue.description.starts_with("Code clone dup:"));
735 assert_eq!(issue.location.lines.end, Some(8));
736 }
737}