1use std::path::PathBuf;
23
24use fallow_core::duplicates::{
25 CloneFamily, CloneFingerprintSet, CloneGroup, DuplicationReport, DuplicationStats,
26 MirroredDirectory, RefactoringSuggestion,
27};
28use fallow_types::envelope::AuditIntroduced;
29use fallow_types::serde_path;
30use serde::Serialize;
31
32use crate::report::dupes_grouping::AttributedCloneGroup;
33
34#[derive(Debug, Clone, Serialize)]
39#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
40pub struct CloneGroupAction {
41 #[serde(rename = "type")]
43 pub kind: CloneGroupActionType,
44 pub auto_fixable: bool,
48 pub description: String,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub comment: Option<String>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[serde(rename_all = "kebab-case")]
62pub enum CloneGroupActionType {
63 ExtractShared,
65 SuppressLine,
68}
69
70#[derive(Debug, Clone, Serialize)]
76#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77pub struct CloneFamilyAction {
78 #[serde(rename = "type")]
80 pub kind: CloneFamilyActionType,
81 pub auto_fixable: bool,
84 pub description: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub note: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub comment: Option<String>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
99#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
100#[serde(rename_all = "kebab-case")]
101pub enum CloneFamilyActionType {
102 ExtractShared,
104 ApplySuggestion,
107 SuppressLine,
110}
111
112const SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";
113const SUPPRESS_DESCRIPTION: &str = "Suppress with an inline comment above the duplicated code";
114
115#[derive(Debug, Clone, Serialize)]
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121pub struct CloneGroupFinding {
122 #[serde(flatten)]
124 pub group: CloneGroup,
125 pub fingerprint: String,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub suggested_name: Option<String>,
138 pub actions: Vec<CloneGroupAction>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub introduced: Option<AuditIntroduced>,
146}
147
148impl CloneGroupFinding {
149 #[allow(
153 dead_code,
154 reason = "kept for focused wrapper tests and non-report construction paths"
155 )]
156 #[must_use]
157 pub fn with_actions(group: CloneGroup) -> Self {
158 let fingerprint = fallow_core::duplicates::clone_fingerprint(&group.instances);
159 Self::with_fingerprint(group, fingerprint)
160 }
161
162 #[must_use]
164 pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
165 let suggested_name = fallow_core::duplicates::deepdive::dominant_identifier(&group);
166 let line_count = group.line_count;
167 let instance_count = group.instances.len();
168 let actions = vec![
169 CloneGroupAction {
170 kind: CloneGroupActionType::ExtractShared,
171 auto_fixable: false,
172 description: format!(
173 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
174 if instance_count == 1 { "" } else { "s" },
175 ),
176 comment: None,
177 },
178 CloneGroupAction {
179 kind: CloneGroupActionType::SuppressLine,
180 auto_fixable: false,
181 description: SUPPRESS_DESCRIPTION.to_string(),
182 comment: Some(SUPPRESS_COMMENT.to_string()),
183 },
184 ];
185 Self {
186 fingerprint,
187 suggested_name,
188 group,
189 actions,
190 introduced: None,
191 }
192 }
193}
194
195#[derive(Debug, Clone, Serialize)]
206#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
207pub struct CloneFamilyFinding {
208 #[serde(serialize_with = "serde_path::serialize_vec")]
210 pub files: Vec<PathBuf>,
211 pub groups: Vec<CloneGroupFinding>,
215 pub total_duplicated_lines: usize,
217 pub total_duplicated_tokens: usize,
219 pub suggestions: Vec<RefactoringSuggestion>,
221 pub actions: Vec<CloneFamilyAction>,
226}
227
228impl CloneFamilyFinding {
229 #[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(family: CloneFamily) -> Self {
238 let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
239 Self::with_fingerprints(family, &fingerprints)
240 }
241
242 #[must_use]
245 pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
246 let actions = build_clone_family_actions(
247 &family.groups,
248 family.total_duplicated_lines,
249 &family.suggestions,
250 );
251 Self {
252 files: family.files,
253 groups: family
254 .groups
255 .into_iter()
256 .map(|group| {
257 let fingerprint = fingerprints.fingerprint_for_group(&group);
258 CloneGroupFinding::with_fingerprint(group, fingerprint)
259 })
260 .collect(),
261 total_duplicated_lines: family.total_duplicated_lines,
262 total_duplicated_tokens: family.total_duplicated_tokens,
263 suggestions: family.suggestions,
264 actions,
265 }
266 }
267}
268
269fn build_clone_family_actions(
270 groups: &[CloneGroup],
271 total_duplicated_lines: usize,
272 suggestions: &[RefactoringSuggestion],
273) -> Vec<CloneFamilyAction> {
274 let group_count = groups.len();
275 let mut actions = Vec::with_capacity(2 + suggestions.len());
276 actions.push(CloneFamilyAction {
277 kind: CloneFamilyActionType::ExtractShared,
278 auto_fixable: false,
279 description: format!(
280 "Extract {group_count} duplicated code block{} ({total_duplicated_lines} lines) into a shared module",
281 if group_count == 1 { "" } else { "s" },
282 ),
283 note: Some(
284 "These clone groups share the same files, indicating a structural relationship; refactor together"
285 .to_string(),
286 ),
287 comment: None,
288 });
289 for suggestion in suggestions {
290 actions.push(CloneFamilyAction {
291 kind: CloneFamilyActionType::ApplySuggestion,
292 auto_fixable: false,
293 description: suggestion.description.clone(),
294 note: None,
295 comment: None,
296 });
297 }
298 actions.push(CloneFamilyAction {
299 kind: CloneFamilyActionType::SuppressLine,
300 auto_fixable: false,
301 description: SUPPRESS_DESCRIPTION.to_string(),
302 note: None,
303 comment: Some(SUPPRESS_COMMENT.to_string()),
304 });
305 actions
306}
307
308#[derive(Debug, Clone, Serialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315pub struct AttributedCloneGroupFinding {
316 #[serde(flatten)]
318 pub group: AttributedCloneGroup,
319 pub fingerprint: String,
324 pub actions: Vec<CloneGroupAction>,
326}
327
328impl AttributedCloneGroupFinding {
329 #[allow(
333 dead_code,
334 reason = "kept for focused wrapper tests and non-report construction paths"
335 )]
336 #[must_use]
337 pub fn with_actions(group: AttributedCloneGroup) -> Self {
338 let fingerprint = group.instances.first().map_or_else(
339 || fallow_core::duplicates::fingerprint_for_fragment(""),
340 |ai| fallow_core::duplicates::fingerprint_for_fragment(&ai.instance.fragment),
341 );
342 Self::with_fingerprint(group, fingerprint)
343 }
344
345 #[must_use]
347 pub fn with_fingerprint(group: AttributedCloneGroup, fingerprint: String) -> Self {
348 let line_count = group.line_count;
349 let instance_count = group.instances.len();
350 let actions = vec![
351 CloneGroupAction {
352 kind: CloneGroupActionType::ExtractShared,
353 auto_fixable: false,
354 description: format!(
355 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
356 if instance_count == 1 { "" } else { "s" },
357 ),
358 comment: None,
359 },
360 CloneGroupAction {
361 kind: CloneGroupActionType::SuppressLine,
362 auto_fixable: false,
363 description: SUPPRESS_DESCRIPTION.to_string(),
364 comment: Some(SUPPRESS_COMMENT.to_string()),
365 },
366 ];
367 Self {
368 group,
369 fingerprint,
370 actions,
371 }
372 }
373}
374
375#[derive(Debug, Clone, Serialize)]
385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
386pub struct DupesReportPayload {
387 pub clone_groups: Vec<CloneGroupFinding>,
389 pub clone_families: Vec<CloneFamilyFinding>,
397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 pub mirrored_directories: Vec<MirroredDirectory>,
400 pub stats: DuplicationStats,
402}
403
404impl DupesReportPayload {
405 #[must_use]
409 pub fn from_report(report: &DuplicationReport) -> Self {
410 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
411 Self {
412 clone_groups: report
413 .clone_groups
414 .iter()
415 .map(|group| {
416 CloneGroupFinding::with_fingerprint(
417 group.clone(),
418 fingerprints.fingerprint_for_group(group),
419 )
420 })
421 .collect(),
422 clone_families: report
423 .clone_families
424 .iter()
425 .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
426 .collect(),
427 mirrored_directories: report.mirrored_directories.clone(),
428 stats: report.stats.clone(),
429 }
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use std::path::PathBuf;
436
437 use fallow_core::duplicates::{
438 CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
439 };
440
441 use super::*;
442
443 fn instance(path: &str) -> CloneInstance {
444 CloneInstance {
445 file: PathBuf::from(path),
446 start_line: 1,
447 end_line: 10,
448 start_col: 0,
449 end_col: 0,
450 fragment: String::new(),
451 }
452 }
453
454 fn group(instances: usize) -> CloneGroup {
455 CloneGroup {
456 instances: (0..instances)
457 .map(|i| instance(&format!("/root/file_{i}.ts")))
458 .collect(),
459 token_count: 100,
460 line_count: 20,
461 }
462 }
463
464 #[test]
465 fn clone_group_finding_position_0_is_extract_shared() {
466 let finding = CloneGroupFinding::with_actions(group(2));
467 assert_eq!(finding.actions.len(), 2);
468 assert_eq!(
469 finding.actions[0].kind,
470 CloneGroupActionType::ExtractShared,
471 "position 0 of a clone group must be `extract-shared` (jq scripts read .actions[0].type)",
472 );
473 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
474 assert!(finding.introduced.is_none());
475 }
476
477 #[test]
478 fn clone_group_finding_surfaces_dominant_identifier() {
479 let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
480 let g = CloneGroup {
481 instances: vec![
482 CloneInstance {
483 file: PathBuf::from("/root/a.ts"),
484 start_line: 1,
485 end_line: 3,
486 start_col: 0,
487 end_col: 0,
488 fragment: fragment.to_string(),
489 },
490 CloneInstance {
491 file: PathBuf::from("/root/b.ts"),
492 start_line: 1,
493 end_line: 3,
494 start_col: 0,
495 end_col: 0,
496 fragment: fragment.to_string(),
497 },
498 ],
499 token_count: 100,
500 line_count: 3,
501 };
502 let finding = CloneGroupFinding::with_actions(g);
503 assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
504 }
505
506 #[test]
507 fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
508 let finding = CloneGroupFinding::with_actions(group(2));
511 assert!(finding.suggested_name.is_none());
512 }
513
514 #[test]
515 fn clone_group_finding_description_pluralises_instance_count() {
516 let single = CloneGroupFinding::with_actions(group(1));
517 assert!(
518 single.actions[0].description.contains("1 instance"),
519 "single instance should be singular: {}",
520 single.actions[0].description
521 );
522 assert!(
523 !single.actions[0].description.contains("1 instances"),
524 "single instance must not pluralise: {}",
525 single.actions[0].description
526 );
527 let multi = CloneGroupFinding::with_actions(group(3));
528 assert!(
529 multi.actions[0].description.contains("3 instances"),
530 "multiple instances must pluralise: {}",
531 multi.actions[0].description
532 );
533 }
534
535 #[test]
536 fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
537 let family = CloneFamily {
538 files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
539 groups: vec![group(2), group(2)],
540 total_duplicated_lines: 40,
541 total_duplicated_tokens: 200,
542 suggestions: vec![
543 RefactoringSuggestion {
544 kind: RefactoringKind::ExtractFunction,
545 description: "Extract helper".to_string(),
546 estimated_savings: 10,
547 },
548 RefactoringSuggestion {
549 kind: RefactoringKind::ExtractModule,
550 description: "Extract module".to_string(),
551 estimated_savings: 30,
552 },
553 ],
554 };
555 let finding = CloneFamilyFinding::with_actions(family);
556 assert_eq!(finding.actions.len(), 4);
557 assert_eq!(
558 finding.actions[0].kind,
559 CloneFamilyActionType::ExtractShared,
560 "position 0 of a clone family must be `extract-shared`",
561 );
562 assert_eq!(
563 finding.actions[1].kind,
564 CloneFamilyActionType::ApplySuggestion
565 );
566 assert_eq!(finding.actions[1].description, "Extract helper");
567 assert_eq!(
568 finding.actions[2].kind,
569 CloneFamilyActionType::ApplySuggestion
570 );
571 assert_eq!(finding.actions[2].description, "Extract module");
572 assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
573 assert_eq!(finding.groups.len(), 2);
574 for inner in &finding.groups {
575 assert_eq!(inner.actions.len(), 2);
576 assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
577 assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
578 }
579 }
580
581 #[test]
582 fn clone_family_finding_with_no_suggestions_emits_two_actions() {
583 let family = CloneFamily {
584 files: vec![PathBuf::from("/root/a.ts")],
585 groups: vec![group(2)],
586 total_duplicated_lines: 20,
587 total_duplicated_tokens: 100,
588 suggestions: Vec::new(),
589 };
590 let finding = CloneFamilyFinding::with_actions(family);
591 assert_eq!(finding.actions.len(), 2);
592 assert_eq!(
593 finding.actions[0].kind,
594 CloneFamilyActionType::ExtractShared
595 );
596 assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
597 }
598
599 #[test]
600 fn payload_from_report_wraps_all_findings() {
601 let report = DuplicationReport {
602 clone_groups: vec![group(2), group(3)],
603 clone_families: vec![CloneFamily {
604 files: vec![PathBuf::from("/root/a.ts")],
605 groups: vec![group(2)],
606 total_duplicated_lines: 20,
607 total_duplicated_tokens: 100,
608 suggestions: Vec::new(),
609 }],
610 mirrored_directories: Vec::new(),
611 stats: DuplicationStats::default(),
612 };
613 let payload = DupesReportPayload::from_report(&report);
614 assert_eq!(payload.clone_groups.len(), 2);
615 assert_eq!(payload.clone_families.len(), 1);
616 for finding in &payload.clone_groups {
617 assert_eq!(finding.actions.len(), 2);
618 }
619 assert_eq!(payload.clone_families[0].actions.len(), 2);
620 }
621
622 #[test]
623 fn attributed_clone_group_finding_actions_match_clone_group_shape() {
624 use crate::report::dupes_grouping::AttributedInstance;
625 let attributed = AttributedCloneGroup {
626 primary_owner: "src".to_string(),
627 token_count: 100,
628 line_count: 20,
629 instances: vec![
630 AttributedInstance {
631 instance: instance("/root/src/a.ts"),
632 owner: "src".to_string(),
633 },
634 AttributedInstance {
635 instance: instance("/root/src/b.ts"),
636 owner: "src".to_string(),
637 },
638 ],
639 };
640 let finding = AttributedCloneGroupFinding::with_actions(attributed);
641 assert_eq!(finding.actions.len(), 2);
642 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
643 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
644 }
645}