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