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 pub actions: Vec<CloneGroupAction>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub introduced: Option<AuditIntroduced>,
138}
139
140impl CloneGroupFinding {
141 #[allow(
145 dead_code,
146 reason = "kept for focused wrapper tests and non-report construction paths"
147 )]
148 #[must_use]
149 pub fn with_actions(group: CloneGroup) -> Self {
150 let fingerprint = fallow_core::duplicates::clone_fingerprint(&group.instances);
151 Self::with_fingerprint(group, fingerprint)
152 }
153
154 #[must_use]
156 pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
157 let line_count = group.line_count;
158 let instance_count = group.instances.len();
159 let actions = vec![
160 CloneGroupAction {
161 kind: CloneGroupActionType::ExtractShared,
162 auto_fixable: false,
163 description: format!(
164 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
165 if instance_count == 1 { "" } else { "s" },
166 ),
167 comment: None,
168 },
169 CloneGroupAction {
170 kind: CloneGroupActionType::SuppressLine,
171 auto_fixable: false,
172 description: SUPPRESS_DESCRIPTION.to_string(),
173 comment: Some(SUPPRESS_COMMENT.to_string()),
174 },
175 ];
176 Self {
177 fingerprint,
178 group,
179 actions,
180 introduced: None,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct CloneFamilyFinding {
198 #[serde(serialize_with = "serde_path::serialize_vec")]
200 pub files: Vec<PathBuf>,
201 pub groups: Vec<CloneGroupFinding>,
205 pub total_duplicated_lines: usize,
207 pub total_duplicated_tokens: usize,
209 pub suggestions: Vec<RefactoringSuggestion>,
211 pub actions: Vec<CloneFamilyAction>,
216}
217
218impl CloneFamilyFinding {
219 #[allow(
223 dead_code,
224 reason = "kept for focused wrapper tests and non-report construction paths"
225 )]
226 #[must_use]
227 pub fn with_actions(family: CloneFamily) -> Self {
228 let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
229 Self::with_fingerprints(family, &fingerprints)
230 }
231
232 #[must_use]
235 pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
236 let actions = build_clone_family_actions(
237 &family.groups,
238 family.total_duplicated_lines,
239 &family.suggestions,
240 );
241 Self {
242 files: family.files,
243 groups: family
244 .groups
245 .into_iter()
246 .map(|group| {
247 let fingerprint = fingerprints.fingerprint_for_group(&group);
248 CloneGroupFinding::with_fingerprint(group, fingerprint)
249 })
250 .collect(),
251 total_duplicated_lines: family.total_duplicated_lines,
252 total_duplicated_tokens: family.total_duplicated_tokens,
253 suggestions: family.suggestions,
254 actions,
255 }
256 }
257}
258
259fn build_clone_family_actions(
260 groups: &[CloneGroup],
261 total_duplicated_lines: usize,
262 suggestions: &[RefactoringSuggestion],
263) -> Vec<CloneFamilyAction> {
264 let group_count = groups.len();
265 let mut actions = Vec::with_capacity(2 + suggestions.len());
266 actions.push(CloneFamilyAction {
267 kind: CloneFamilyActionType::ExtractShared,
268 auto_fixable: false,
269 description: format!(
270 "Extract {group_count} duplicated code block{} ({total_duplicated_lines} lines) into a shared module",
271 if group_count == 1 { "" } else { "s" },
272 ),
273 note: Some(
274 "These clone groups share the same files, indicating a structural relationship; refactor together"
275 .to_string(),
276 ),
277 comment: None,
278 });
279 for suggestion in suggestions {
280 actions.push(CloneFamilyAction {
281 kind: CloneFamilyActionType::ApplySuggestion,
282 auto_fixable: false,
283 description: suggestion.description.clone(),
284 note: None,
285 comment: None,
286 });
287 }
288 actions.push(CloneFamilyAction {
289 kind: CloneFamilyActionType::SuppressLine,
290 auto_fixable: false,
291 description: SUPPRESS_DESCRIPTION.to_string(),
292 note: None,
293 comment: Some(SUPPRESS_COMMENT.to_string()),
294 });
295 actions
296}
297
298#[derive(Debug, Clone, Serialize)]
304#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
305pub struct AttributedCloneGroupFinding {
306 #[serde(flatten)]
308 pub group: AttributedCloneGroup,
309 pub fingerprint: String,
314 pub actions: Vec<CloneGroupAction>,
316}
317
318impl AttributedCloneGroupFinding {
319 #[allow(
323 dead_code,
324 reason = "kept for focused wrapper tests and non-report construction paths"
325 )]
326 #[must_use]
327 pub fn with_actions(group: AttributedCloneGroup) -> Self {
328 let fingerprint = group.instances.first().map_or_else(
329 || fallow_core::duplicates::fingerprint_for_fragment(""),
330 |ai| fallow_core::duplicates::fingerprint_for_fragment(&ai.instance.fragment),
331 );
332 Self::with_fingerprint(group, fingerprint)
333 }
334
335 #[must_use]
337 pub fn with_fingerprint(group: AttributedCloneGroup, fingerprint: String) -> Self {
338 let line_count = group.line_count;
339 let instance_count = group.instances.len();
340 let actions = vec![
341 CloneGroupAction {
342 kind: CloneGroupActionType::ExtractShared,
343 auto_fixable: false,
344 description: format!(
345 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
346 if instance_count == 1 { "" } else { "s" },
347 ),
348 comment: None,
349 },
350 CloneGroupAction {
351 kind: CloneGroupActionType::SuppressLine,
352 auto_fixable: false,
353 description: SUPPRESS_DESCRIPTION.to_string(),
354 comment: Some(SUPPRESS_COMMENT.to_string()),
355 },
356 ];
357 Self {
358 group,
359 fingerprint,
360 actions,
361 }
362 }
363}
364
365#[derive(Debug, Clone, Serialize)]
375#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
376pub struct DupesReportPayload {
377 pub clone_groups: Vec<CloneGroupFinding>,
379 pub clone_families: Vec<CloneFamilyFinding>,
387 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 pub mirrored_directories: Vec<MirroredDirectory>,
390 pub stats: DuplicationStats,
392}
393
394impl DupesReportPayload {
395 #[must_use]
399 pub fn from_report(report: &DuplicationReport) -> Self {
400 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
401 Self {
402 clone_groups: report
403 .clone_groups
404 .iter()
405 .map(|group| {
406 CloneGroupFinding::with_fingerprint(
407 group.clone(),
408 fingerprints.fingerprint_for_group(group),
409 )
410 })
411 .collect(),
412 clone_families: report
413 .clone_families
414 .iter()
415 .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
416 .collect(),
417 mirrored_directories: report.mirrored_directories.clone(),
418 stats: report.stats.clone(),
419 }
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use std::path::PathBuf;
426
427 use fallow_core::duplicates::{
428 CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
429 };
430
431 use super::*;
432
433 fn instance(path: &str) -> CloneInstance {
434 CloneInstance {
435 file: PathBuf::from(path),
436 start_line: 1,
437 end_line: 10,
438 start_col: 0,
439 end_col: 0,
440 fragment: String::new(),
441 }
442 }
443
444 fn group(instances: usize) -> CloneGroup {
445 CloneGroup {
446 instances: (0..instances)
447 .map(|i| instance(&format!("/root/file_{i}.ts")))
448 .collect(),
449 token_count: 100,
450 line_count: 20,
451 }
452 }
453
454 #[test]
455 fn clone_group_finding_position_0_is_extract_shared() {
456 let finding = CloneGroupFinding::with_actions(group(2));
457 assert_eq!(finding.actions.len(), 2);
458 assert_eq!(
459 finding.actions[0].kind,
460 CloneGroupActionType::ExtractShared,
461 "position 0 of a clone group must be `extract-shared` (jq scripts read .actions[0].type)",
462 );
463 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
464 assert!(finding.introduced.is_none());
465 }
466
467 #[test]
468 fn clone_group_finding_description_pluralises_instance_count() {
469 let single = CloneGroupFinding::with_actions(group(1));
470 assert!(
471 single.actions[0].description.contains("1 instance"),
472 "single instance should be singular: {}",
473 single.actions[0].description
474 );
475 assert!(
476 !single.actions[0].description.contains("1 instances"),
477 "single instance must not pluralise: {}",
478 single.actions[0].description
479 );
480 let multi = CloneGroupFinding::with_actions(group(3));
481 assert!(
482 multi.actions[0].description.contains("3 instances"),
483 "multiple instances must pluralise: {}",
484 multi.actions[0].description
485 );
486 }
487
488 #[test]
489 fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
490 let family = CloneFamily {
491 files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
492 groups: vec![group(2), group(2)],
493 total_duplicated_lines: 40,
494 total_duplicated_tokens: 200,
495 suggestions: vec![
496 RefactoringSuggestion {
497 kind: RefactoringKind::ExtractFunction,
498 description: "Extract helper".to_string(),
499 estimated_savings: 10,
500 },
501 RefactoringSuggestion {
502 kind: RefactoringKind::ExtractModule,
503 description: "Extract module".to_string(),
504 estimated_savings: 30,
505 },
506 ],
507 };
508 let finding = CloneFamilyFinding::with_actions(family);
509 assert_eq!(finding.actions.len(), 4);
511 assert_eq!(
512 finding.actions[0].kind,
513 CloneFamilyActionType::ExtractShared,
514 "position 0 of a clone family must be `extract-shared`",
515 );
516 assert_eq!(
517 finding.actions[1].kind,
518 CloneFamilyActionType::ApplySuggestion
519 );
520 assert_eq!(finding.actions[1].description, "Extract helper");
521 assert_eq!(
522 finding.actions[2].kind,
523 CloneFamilyActionType::ApplySuggestion
524 );
525 assert_eq!(finding.actions[2].description, "Extract module");
526 assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
527 assert_eq!(finding.groups.len(), 2);
530 for inner in &finding.groups {
531 assert_eq!(inner.actions.len(), 2);
532 assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
533 assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
534 }
535 }
536
537 #[test]
538 fn clone_family_finding_with_no_suggestions_emits_two_actions() {
539 let family = CloneFamily {
540 files: vec![PathBuf::from("/root/a.ts")],
541 groups: vec![group(2)],
542 total_duplicated_lines: 20,
543 total_duplicated_tokens: 100,
544 suggestions: Vec::new(),
545 };
546 let finding = CloneFamilyFinding::with_actions(family);
547 assert_eq!(finding.actions.len(), 2);
548 assert_eq!(
549 finding.actions[0].kind,
550 CloneFamilyActionType::ExtractShared
551 );
552 assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
553 }
554
555 #[test]
556 fn payload_from_report_wraps_all_findings() {
557 let report = DuplicationReport {
558 clone_groups: vec![group(2), group(3)],
559 clone_families: vec![CloneFamily {
560 files: vec![PathBuf::from("/root/a.ts")],
561 groups: vec![group(2)],
562 total_duplicated_lines: 20,
563 total_duplicated_tokens: 100,
564 suggestions: Vec::new(),
565 }],
566 mirrored_directories: Vec::new(),
567 stats: DuplicationStats::default(),
568 };
569 let payload = DupesReportPayload::from_report(&report);
570 assert_eq!(payload.clone_groups.len(), 2);
571 assert_eq!(payload.clone_families.len(), 1);
572 for finding in &payload.clone_groups {
574 assert_eq!(finding.actions.len(), 2);
575 }
576 assert_eq!(payload.clone_families[0].actions.len(), 2);
578 }
579
580 #[test]
581 fn attributed_clone_group_finding_actions_match_clone_group_shape() {
582 use crate::report::dupes_grouping::AttributedInstance;
583 let attributed = AttributedCloneGroup {
584 primary_owner: "src".to_string(),
585 token_count: 100,
586 line_count: 20,
587 instances: vec![
588 AttributedInstance {
589 instance: instance("/root/src/a.ts"),
590 owner: "src".to_string(),
591 },
592 AttributedInstance {
593 instance: instance("/root/src/b.ts"),
594 owner: "src".to_string(),
595 },
596 ],
597 };
598 let finding = AttributedCloneGroupFinding::with_actions(attributed);
599 assert_eq!(finding.actions.len(), 2);
600 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
601 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
602 }
603}