1use std::path::PathBuf;
23
24use fallow_core::duplicates::{
25 CloneFamily, CloneGroup, DuplicationReport, DuplicationStats, MirroredDirectory,
26 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 actions: Vec<CloneGroupAction>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub introduced: Option<AuditIntroduced>,
133}
134
135impl CloneGroupFinding {
136 #[must_use]
140 pub fn with_actions(group: CloneGroup) -> Self {
141 let line_count = group.line_count;
142 let instance_count = group.instances.len();
143 let actions = vec![
144 CloneGroupAction {
145 kind: CloneGroupActionType::ExtractShared,
146 auto_fixable: false,
147 description: format!(
148 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
149 if instance_count == 1 { "" } else { "s" },
150 ),
151 comment: None,
152 },
153 CloneGroupAction {
154 kind: CloneGroupActionType::SuppressLine,
155 auto_fixable: false,
156 description: SUPPRESS_DESCRIPTION.to_string(),
157 comment: Some(SUPPRESS_COMMENT.to_string()),
158 },
159 ];
160 Self {
161 group,
162 actions,
163 introduced: None,
164 }
165 }
166}
167
168#[derive(Debug, Clone, Serialize)]
179#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
180pub struct CloneFamilyFinding {
181 #[serde(serialize_with = "serde_path::serialize_vec")]
183 pub files: Vec<PathBuf>,
184 pub groups: Vec<CloneGroupFinding>,
188 pub total_duplicated_lines: usize,
190 pub total_duplicated_tokens: usize,
192 pub suggestions: Vec<RefactoringSuggestion>,
194 pub actions: Vec<CloneFamilyAction>,
199}
200
201impl CloneFamilyFinding {
202 #[must_use]
206 pub fn with_actions(family: CloneFamily) -> Self {
207 let actions = build_clone_family_actions(
208 &family.groups,
209 family.total_duplicated_lines,
210 &family.suggestions,
211 );
212 Self {
213 files: family.files,
214 groups: family
215 .groups
216 .into_iter()
217 .map(CloneGroupFinding::with_actions)
218 .collect(),
219 total_duplicated_lines: family.total_duplicated_lines,
220 total_duplicated_tokens: family.total_duplicated_tokens,
221 suggestions: family.suggestions,
222 actions,
223 }
224 }
225}
226
227fn build_clone_family_actions(
228 groups: &[CloneGroup],
229 total_duplicated_lines: usize,
230 suggestions: &[RefactoringSuggestion],
231) -> Vec<CloneFamilyAction> {
232 let group_count = groups.len();
233 let mut actions = Vec::with_capacity(2 + suggestions.len());
234 actions.push(CloneFamilyAction {
235 kind: CloneFamilyActionType::ExtractShared,
236 auto_fixable: false,
237 description: format!(
238 "Extract {group_count} duplicated code block{} ({total_duplicated_lines} lines) into a shared module",
239 if group_count == 1 { "" } else { "s" },
240 ),
241 note: Some(
242 "These clone groups share the same files, indicating a structural relationship; refactor together"
243 .to_string(),
244 ),
245 comment: None,
246 });
247 for suggestion in suggestions {
248 actions.push(CloneFamilyAction {
249 kind: CloneFamilyActionType::ApplySuggestion,
250 auto_fixable: false,
251 description: suggestion.description.clone(),
252 note: None,
253 comment: None,
254 });
255 }
256 actions.push(CloneFamilyAction {
257 kind: CloneFamilyActionType::SuppressLine,
258 auto_fixable: false,
259 description: SUPPRESS_DESCRIPTION.to_string(),
260 note: None,
261 comment: Some(SUPPRESS_COMMENT.to_string()),
262 });
263 actions
264}
265
266#[derive(Debug, Clone, Serialize)]
272#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
273pub struct AttributedCloneGroupFinding {
274 #[serde(flatten)]
276 pub group: AttributedCloneGroup,
277 pub actions: Vec<CloneGroupAction>,
279}
280
281impl AttributedCloneGroupFinding {
282 #[must_use]
286 pub fn with_actions(group: AttributedCloneGroup) -> Self {
287 let line_count = group.line_count;
288 let instance_count = group.instances.len();
289 let actions = vec![
290 CloneGroupAction {
291 kind: CloneGroupActionType::ExtractShared,
292 auto_fixable: false,
293 description: format!(
294 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
295 if instance_count == 1 { "" } else { "s" },
296 ),
297 comment: None,
298 },
299 CloneGroupAction {
300 kind: CloneGroupActionType::SuppressLine,
301 auto_fixable: false,
302 description: SUPPRESS_DESCRIPTION.to_string(),
303 comment: Some(SUPPRESS_COMMENT.to_string()),
304 },
305 ];
306 Self { group, actions }
307 }
308}
309
310#[derive(Debug, Clone, Serialize)]
320#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
321pub struct DupesReportPayload {
322 pub clone_groups: Vec<CloneGroupFinding>,
324 pub clone_families: Vec<CloneFamilyFinding>,
332 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 pub mirrored_directories: Vec<MirroredDirectory>,
335 pub stats: DuplicationStats,
337}
338
339impl DupesReportPayload {
340 #[must_use]
344 pub fn from_report(report: &DuplicationReport) -> Self {
345 Self {
346 clone_groups: report
347 .clone_groups
348 .iter()
349 .cloned()
350 .map(CloneGroupFinding::with_actions)
351 .collect(),
352 clone_families: report
353 .clone_families
354 .iter()
355 .cloned()
356 .map(CloneFamilyFinding::with_actions)
357 .collect(),
358 mirrored_directories: report.mirrored_directories.clone(),
359 stats: report.stats.clone(),
360 }
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use std::path::PathBuf;
367
368 use fallow_core::duplicates::{
369 CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
370 };
371
372 use super::*;
373
374 fn instance(path: &str) -> CloneInstance {
375 CloneInstance {
376 file: PathBuf::from(path),
377 start_line: 1,
378 end_line: 10,
379 start_col: 0,
380 end_col: 0,
381 fragment: String::new(),
382 }
383 }
384
385 fn group(instances: usize) -> CloneGroup {
386 CloneGroup {
387 instances: (0..instances)
388 .map(|i| instance(&format!("/root/file_{i}.ts")))
389 .collect(),
390 token_count: 100,
391 line_count: 20,
392 }
393 }
394
395 #[test]
396 fn clone_group_finding_position_0_is_extract_shared() {
397 let finding = CloneGroupFinding::with_actions(group(2));
398 assert_eq!(finding.actions.len(), 2);
399 assert_eq!(
400 finding.actions[0].kind,
401 CloneGroupActionType::ExtractShared,
402 "position 0 of a clone group must be `extract-shared` (jq scripts read .actions[0].type)",
403 );
404 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
405 assert!(finding.introduced.is_none());
406 }
407
408 #[test]
409 fn clone_group_finding_description_pluralises_instance_count() {
410 let single = CloneGroupFinding::with_actions(group(1));
411 assert!(
412 single.actions[0].description.contains("1 instance"),
413 "single instance should be singular: {}",
414 single.actions[0].description
415 );
416 assert!(
417 !single.actions[0].description.contains("1 instances"),
418 "single instance must not pluralise: {}",
419 single.actions[0].description
420 );
421 let multi = CloneGroupFinding::with_actions(group(3));
422 assert!(
423 multi.actions[0].description.contains("3 instances"),
424 "multiple instances must pluralise: {}",
425 multi.actions[0].description
426 );
427 }
428
429 #[test]
430 fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
431 let family = CloneFamily {
432 files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
433 groups: vec![group(2), group(2)],
434 total_duplicated_lines: 40,
435 total_duplicated_tokens: 200,
436 suggestions: vec![
437 RefactoringSuggestion {
438 kind: RefactoringKind::ExtractFunction,
439 description: "Extract helper".to_string(),
440 estimated_savings: 10,
441 },
442 RefactoringSuggestion {
443 kind: RefactoringKind::ExtractModule,
444 description: "Extract module".to_string(),
445 estimated_savings: 30,
446 },
447 ],
448 };
449 let finding = CloneFamilyFinding::with_actions(family);
450 assert_eq!(finding.actions.len(), 4);
452 assert_eq!(
453 finding.actions[0].kind,
454 CloneFamilyActionType::ExtractShared,
455 "position 0 of a clone family must be `extract-shared`",
456 );
457 assert_eq!(
458 finding.actions[1].kind,
459 CloneFamilyActionType::ApplySuggestion
460 );
461 assert_eq!(finding.actions[1].description, "Extract helper");
462 assert_eq!(
463 finding.actions[2].kind,
464 CloneFamilyActionType::ApplySuggestion
465 );
466 assert_eq!(finding.actions[2].description, "Extract module");
467 assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
468 assert_eq!(finding.groups.len(), 2);
471 for inner in &finding.groups {
472 assert_eq!(inner.actions.len(), 2);
473 assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
474 assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
475 }
476 }
477
478 #[test]
479 fn clone_family_finding_with_no_suggestions_emits_two_actions() {
480 let family = CloneFamily {
481 files: vec![PathBuf::from("/root/a.ts")],
482 groups: vec![group(2)],
483 total_duplicated_lines: 20,
484 total_duplicated_tokens: 100,
485 suggestions: Vec::new(),
486 };
487 let finding = CloneFamilyFinding::with_actions(family);
488 assert_eq!(finding.actions.len(), 2);
489 assert_eq!(
490 finding.actions[0].kind,
491 CloneFamilyActionType::ExtractShared
492 );
493 assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
494 }
495
496 #[test]
497 fn payload_from_report_wraps_all_findings() {
498 let report = DuplicationReport {
499 clone_groups: vec![group(2), group(3)],
500 clone_families: vec![CloneFamily {
501 files: vec![PathBuf::from("/root/a.ts")],
502 groups: vec![group(2)],
503 total_duplicated_lines: 20,
504 total_duplicated_tokens: 100,
505 suggestions: Vec::new(),
506 }],
507 mirrored_directories: Vec::new(),
508 stats: DuplicationStats::default(),
509 };
510 let payload = DupesReportPayload::from_report(&report);
511 assert_eq!(payload.clone_groups.len(), 2);
512 assert_eq!(payload.clone_families.len(), 1);
513 for finding in &payload.clone_groups {
515 assert_eq!(finding.actions.len(), 2);
516 }
517 assert_eq!(payload.clone_families[0].actions.len(), 2);
519 }
520
521 #[test]
522 fn attributed_clone_group_finding_actions_match_clone_group_shape() {
523 use crate::report::dupes_grouping::AttributedInstance;
524 let attributed = AttributedCloneGroup {
525 primary_owner: "src".to_string(),
526 token_count: 100,
527 line_count: 20,
528 instances: vec![
529 AttributedInstance {
530 instance: instance("/root/src/a.ts"),
531 owner: "src".to_string(),
532 },
533 AttributedInstance {
534 instance: instance("/root/src/b.ts"),
535 owner: "src".to_string(),
536 },
537 ],
538 };
539 let finding = AttributedCloneGroupFinding::with_actions(attributed);
540 assert_eq!(finding.actions.len(), 2);
541 assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
542 assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
543 }
544}