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