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,
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 pub instances: Vec<AttributedInstance>,
54}
55
56impl AttributedCloneGroup {
57 #[must_use]
59 pub fn fingerprint(&self, fingerprints: &CloneFingerprintSet) -> String {
60 let instances: Vec<_> = self
61 .instances
62 .iter()
63 .map(|instance| instance.instance.clone())
64 .collect();
65 fingerprints.fingerprint_for_parts(&instances, self.token_count, self.line_count)
66 }
67}
68
69#[derive(Debug, Clone, Serialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76pub struct AttributedCloneGroupFinding {
77 #[serde(flatten)]
79 pub group: AttributedCloneGroup,
80 pub fingerprint: String,
85 pub actions: Vec<CloneGroupAction>,
87}
88
89impl AttributedCloneGroupFinding {
90 #[allow(
92 dead_code,
93 reason = "kept for focused wrapper tests and non-report construction paths"
94 )]
95 #[must_use]
96 pub fn with_actions(group: AttributedCloneGroup) -> Self {
97 let fingerprint = group.instances.first().map_or_else(
98 || fingerprint_for_fragment(""),
99 |ai| fingerprint_for_fragment(&ai.instance.fragment),
100 );
101 Self::with_fingerprint(group, fingerprint)
102 }
103
104 #[must_use]
106 pub fn with_fingerprint(group: AttributedCloneGroup, fingerprint: String) -> Self {
107 let actions = clone_group_actions(group.line_count, group.instances.len());
108 Self {
109 group,
110 fingerprint,
111 actions,
112 }
113 }
114}
115
116#[derive(Debug, Clone, Serialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub struct DuplicationGroup {
121 pub key: String,
125 pub stats: DuplicationStats,
127 pub clone_groups: Vec<AttributedCloneGroupFinding>,
132 pub clone_families: Vec<CloneFamilyFinding>,
135}
136
137#[derive(Debug, Clone, Serialize)]
139pub struct DuplicationGrouping {
140 pub mode: &'static str,
142 pub groups: Vec<DuplicationGroup>,
144}
145
146#[derive(Debug, Clone, Serialize)]
152#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
153pub struct CloneGroupFinding {
154 #[serde(flatten)]
156 pub group: CloneGroup,
157 pub fingerprint: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub suggested_name: Option<String>,
170 pub actions: Vec<CloneGroupAction>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub introduced: Option<AuditIntroduced>,
178}
179
180impl CloneGroupFinding {
181 #[allow(
183 dead_code,
184 reason = "kept for focused wrapper tests and non-report construction paths"
185 )]
186 #[must_use]
187 pub fn with_actions(group: CloneGroup) -> Self {
188 let fingerprint = clone_fingerprint(&group.instances);
189 Self::with_fingerprint(group, fingerprint)
190 }
191
192 #[must_use]
194 pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
195 let suggested_name = dominant_identifier(&group);
196 let actions = clone_group_actions(group.line_count, group.instances.len());
197 Self {
198 fingerprint,
199 suggested_name,
200 group,
201 actions,
202 introduced: None,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Serialize)]
218#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
219pub struct CloneFamilyFinding {
220 #[serde(serialize_with = "serde_path::serialize_vec")]
222 pub files: Vec<PathBuf>,
223 pub groups: Vec<CloneGroupFinding>,
227 pub total_duplicated_lines: usize,
229 pub total_duplicated_tokens: usize,
231 pub suggestions: Vec<RefactoringSuggestion>,
233 pub actions: Vec<CloneFamilyAction>,
238}
239
240impl CloneFamilyFinding {
241 #[allow(
243 dead_code,
244 reason = "kept for focused wrapper tests and non-report construction paths"
245 )]
246 #[must_use]
247 pub fn with_actions(family: CloneFamily) -> Self {
248 let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
249 Self::with_fingerprints(family, &fingerprints)
250 }
251
252 #[must_use]
255 pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
256 let actions = build_clone_family_actions(
257 &family.groups,
258 family.total_duplicated_lines,
259 &family.suggestions,
260 );
261 Self {
262 files: family.files,
263 groups: family
264 .groups
265 .into_iter()
266 .map(|group| {
267 let fingerprint = fingerprints.fingerprint_for_group(&group);
268 CloneGroupFinding::with_fingerprint(group, fingerprint)
269 })
270 .collect(),
271 total_duplicated_lines: family.total_duplicated_lines,
272 total_duplicated_tokens: family.total_duplicated_tokens,
273 suggestions: family.suggestions,
274 actions,
275 }
276 }
277}
278
279fn build_clone_family_actions(
280 groups: &[CloneGroup],
281 total_duplicated_lines: usize,
282 suggestions: &[RefactoringSuggestion],
283) -> Vec<CloneFamilyAction> {
284 clone_family_actions(
285 groups.len(),
286 total_duplicated_lines,
287 suggestions
288 .iter()
289 .map(|suggestion| suggestion.description.as_str()),
290 )
291}
292
293#[derive(Debug, Clone, Serialize)]
303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
304pub struct DupesReportPayload {
305 pub clone_groups: Vec<CloneGroupFinding>,
307 pub clone_families: Vec<CloneFamilyFinding>,
315 #[serde(default, skip_serializing_if = "Vec::is_empty")]
317 pub mirrored_directories: Vec<MirroredDirectory>,
318 pub stats: DuplicationStats,
320}
321
322impl DupesReportPayload {
323 #[must_use]
325 pub fn from_report(report: &DuplicationReport) -> Self {
326 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
327 Self {
328 clone_groups: report
329 .clone_groups
330 .iter()
331 .map(|group| {
332 CloneGroupFinding::with_fingerprint(
333 group.clone(),
334 fingerprints.fingerprint_for_group(group),
335 )
336 })
337 .collect(),
338 clone_families: report
339 .clone_families
340 .iter()
341 .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
342 .collect(),
343 mirrored_directories: report.mirrored_directories.clone(),
344 stats: report.stats.clone(),
345 }
346 }
347}
348
349#[must_use]
355#[expect(
356 clippy::cast_possible_truncation,
357 reason = "line numbers are bounded by source size"
358)]
359pub fn build_duplication_codeclimate(
360 report: &DuplicationReport,
361 root: &Path,
362) -> Vec<CodeClimateIssue> {
363 let mut issues = Vec::new();
364
365 for (i, group) in report.clone_groups.iter().enumerate() {
366 let token_str = group.token_count.to_string();
367 let line_count_str = group.line_count.to_string();
368 let fragment_prefix: String = group
369 .instances
370 .first()
371 .map(|inst| inst.fragment.chars().take(64).collect())
372 .unwrap_or_default();
373
374 for instance in &group.instances {
375 let path = codeclimate_path(&instance.file, root);
376 let start_str = instance.start_line.to_string();
377 let fp = codeclimate_fingerprint_hash(&[
378 "fallow/code-duplication",
379 &path,
380 &start_str,
381 &token_str,
382 &line_count_str,
383 &fragment_prefix,
384 ]);
385 issues.push(fallow_output::build_codeclimate_issue(
386 CodeClimateIssueInput {
387 check_name: "fallow/code-duplication",
388 description: &format!(
389 "Code clone group {} ({} lines, {} instances)",
390 i + 1,
391 group.line_count,
392 group.instances.len()
393 ),
394 severity: CodeClimateSeverity::Minor,
395 category: "Duplication",
396 path: &path,
397 begin_line: Some(instance.start_line as u32),
398 fingerprint: &fp,
399 },
400 ));
401 }
402 }
403
404 issues
405}
406
407fn codeclimate_path(path: &Path, root: &Path) -> String {
408 normalize_uri(
409 &path
410 .strip_prefix(root)
411 .unwrap_or(path)
412 .display()
413 .to_string(),
414 )
415}
416
417#[cfg(test)]
418mod tests {
419 use std::path::Path;
420
421 use fallow_output::{CloneFamilyActionType, CloneGroupActionType};
422 use fallow_types::duplicates::{
423 CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
424 };
425
426 use super::*;
427
428 fn instance(path: &str) -> CloneInstance {
429 CloneInstance {
430 file: PathBuf::from(path),
431 start_line: 1,
432 end_line: 10,
433 start_col: 0,
434 end_col: 0,
435 fragment: String::new(),
436 }
437 }
438
439 fn group(instances: usize) -> CloneGroup {
440 CloneGroup {
441 instances: (0..instances)
442 .map(|i| instance(&format!("/root/file_{i}.ts")))
443 .collect(),
444 token_count: 100,
445 line_count: 20,
446 }
447 }
448
449 #[test]
450 fn clone_group_finding_position_0_is_extract_shared() {
451 let finding = CloneGroupFinding::with_actions(group(2));
452 assert_eq!(finding.actions.len(), 2);
453 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
454 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
455 assert!(finding.introduced.is_none());
456 }
457
458 #[test]
459 fn attributed_clone_group_finding_actions_match_clone_group_shape() {
460 let attributed = AttributedCloneGroup {
461 primary_owner: "src".to_string(),
462 token_count: 100,
463 line_count: 20,
464 instances: vec![
465 AttributedInstance {
466 instance: instance("/root/src/a.ts"),
467 owner: "src".to_string(),
468 },
469 AttributedInstance {
470 instance: instance("/root/src/b.ts"),
471 owner: "src".to_string(),
472 },
473 ],
474 };
475 let finding = AttributedCloneGroupFinding::with_actions(attributed);
476 assert_eq!(finding.actions.len(), 2);
477 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
478 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
479 }
480
481 #[test]
482 fn clone_group_finding_surfaces_dominant_identifier() {
483 let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
484 let g = CloneGroup {
485 instances: vec![
486 CloneInstance {
487 file: PathBuf::from("/root/a.ts"),
488 start_line: 1,
489 end_line: 3,
490 start_col: 0,
491 end_col: 0,
492 fragment: fragment.to_string(),
493 },
494 CloneInstance {
495 file: PathBuf::from("/root/b.ts"),
496 start_line: 1,
497 end_line: 3,
498 start_col: 0,
499 end_col: 0,
500 fragment: fragment.to_string(),
501 },
502 ],
503 token_count: 100,
504 line_count: 3,
505 };
506 let finding = CloneGroupFinding::with_actions(g);
507 assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
508 }
509
510 #[test]
511 fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
512 let finding = CloneGroupFinding::with_actions(group(2));
513 assert!(finding.suggested_name.is_none());
514 }
515
516 #[test]
517 fn clone_group_finding_description_pluralises_instance_count() {
518 let single = CloneGroupFinding::with_actions(group(1));
519 assert!(single.actions[0].description.contains("1 instance"));
520 assert!(!single.actions[0].description.contains("1 instances"));
521 let multi = CloneGroupFinding::with_actions(group(3));
522 assert!(multi.actions[0].description.contains("3 instances"));
523 }
524
525 #[test]
526 fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
527 let family = CloneFamily {
528 files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
529 groups: vec![group(2), group(2)],
530 total_duplicated_lines: 40,
531 total_duplicated_tokens: 200,
532 suggestions: vec![
533 RefactoringSuggestion {
534 kind: RefactoringKind::ExtractFunction,
535 description: "Extract helper".to_string(),
536 estimated_savings: 10,
537 },
538 RefactoringSuggestion {
539 kind: RefactoringKind::ExtractModule,
540 description: "Extract module".to_string(),
541 estimated_savings: 30,
542 },
543 ],
544 };
545 let finding = CloneFamilyFinding::with_actions(family);
546 assert_eq!(finding.actions.len(), 4);
547 assert_eq!(
548 finding.actions[0].kind,
549 CloneFamilyActionType::ExtractShared
550 );
551 assert_eq!(
552 finding.actions[1].kind,
553 CloneFamilyActionType::ApplySuggestion
554 );
555 assert_eq!(finding.actions[1].description, "Extract helper");
556 assert_eq!(
557 finding.actions[2].kind,
558 CloneFamilyActionType::ApplySuggestion
559 );
560 assert_eq!(finding.actions[2].description, "Extract module");
561 assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
562 assert_eq!(finding.groups.len(), 2);
563 for inner in &finding.groups {
564 assert_eq!(inner.actions.len(), 2);
565 assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
566 assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
567 }
568 }
569
570 #[test]
571 fn clone_family_finding_with_no_suggestions_emits_two_actions() {
572 let family = CloneFamily {
573 files: vec![PathBuf::from("/root/a.ts")],
574 groups: vec![group(2)],
575 total_duplicated_lines: 20,
576 total_duplicated_tokens: 100,
577 suggestions: Vec::new(),
578 };
579 let finding = CloneFamilyFinding::with_actions(family);
580 assert_eq!(finding.actions.len(), 2);
581 assert_eq!(
582 finding.actions[0].kind,
583 CloneFamilyActionType::ExtractShared
584 );
585 assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
586 }
587
588 #[test]
589 fn payload_from_report_wraps_all_findings() {
590 let report = DuplicationReport {
591 clone_groups: vec![group(2), group(3)],
592 clone_families: vec![CloneFamily {
593 files: vec![PathBuf::from("/root/a.ts")],
594 groups: vec![group(2)],
595 total_duplicated_lines: 20,
596 total_duplicated_tokens: 100,
597 suggestions: Vec::new(),
598 }],
599 mirrored_directories: Vec::new(),
600 stats: DuplicationStats::default(),
601 };
602 let payload = DupesReportPayload::from_report(&report);
603 assert_eq!(payload.clone_groups.len(), 2);
604 assert_eq!(payload.clone_families.len(), 1);
605 for finding in &payload.clone_groups {
606 assert_eq!(finding.actions.len(), 2);
607 }
608 assert_eq!(payload.clone_families[0].actions.len(), 2);
609 }
610
611 #[test]
612 fn duplication_codeclimate_uses_relative_normalized_paths() {
613 let report = DuplicationReport {
614 clone_groups: vec![CloneGroup {
615 instances: vec![CloneInstance {
616 file: PathBuf::from("/root/app/[id]/page.tsx"),
617 start_line: 4,
618 end_line: 8,
619 start_col: 0,
620 end_col: 0,
621 fragment: "const duplicate = 1;".to_string(),
622 }],
623 token_count: 42,
624 line_count: 5,
625 }],
626 clone_families: Vec::new(),
627 mirrored_directories: Vec::new(),
628 stats: DuplicationStats::default(),
629 };
630
631 let issues = build_duplication_codeclimate(&report, Path::new("/root"));
632
633 assert_eq!(issues.len(), 1);
634 let issue = &issues[0];
635 assert_eq!(issue.check_name, "fallow/code-duplication");
636 assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
637 assert_eq!(issue.location.lines.begin, 4);
638 assert_eq!(issue.categories, vec!["Duplication"]);
639 assert!(issue.description.contains("Code clone group 1"));
640 }
641}