use std::path::PathBuf;
use fallow_core::duplicates::{
CloneFamily, CloneGroup, DuplicationReport, DuplicationStats, MirroredDirectory,
RefactoringSuggestion,
};
use fallow_types::envelope::AuditIntroduced;
use fallow_types::serde_path;
use serde::Serialize;
use crate::report::dupes_grouping::AttributedCloneGroup;
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CloneGroupAction {
#[serde(rename = "type")]
pub kind: CloneGroupActionType,
pub auto_fixable: bool,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum CloneGroupActionType {
ExtractShared,
SuppressLine,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CloneFamilyAction {
#[serde(rename = "type")]
pub kind: CloneFamilyActionType,
pub auto_fixable: bool,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum CloneFamilyActionType {
ExtractShared,
ApplySuggestion,
SuppressLine,
}
const SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";
const SUPPRESS_DESCRIPTION: &str = "Suppress with an inline comment above the duplicated code";
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CloneGroupFinding {
#[serde(flatten)]
pub group: CloneGroup,
pub actions: Vec<CloneGroupAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub introduced: Option<AuditIntroduced>,
}
impl CloneGroupFinding {
#[must_use]
pub fn with_actions(group: CloneGroup) -> Self {
let line_count = group.line_count;
let instance_count = group.instances.len();
let actions = vec![
CloneGroupAction {
kind: CloneGroupActionType::ExtractShared,
auto_fixable: false,
description: format!(
"Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
if instance_count == 1 { "" } else { "s" },
),
comment: None,
},
CloneGroupAction {
kind: CloneGroupActionType::SuppressLine,
auto_fixable: false,
description: SUPPRESS_DESCRIPTION.to_string(),
comment: Some(SUPPRESS_COMMENT.to_string()),
},
];
Self {
group,
actions,
introduced: None,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CloneFamilyFinding {
#[serde(serialize_with = "serde_path::serialize_vec")]
pub files: Vec<PathBuf>,
pub groups: Vec<CloneGroupFinding>,
pub total_duplicated_lines: usize,
pub total_duplicated_tokens: usize,
pub suggestions: Vec<RefactoringSuggestion>,
pub actions: Vec<CloneFamilyAction>,
}
impl CloneFamilyFinding {
#[must_use]
pub fn with_actions(family: CloneFamily) -> Self {
let actions = build_clone_family_actions(
&family.groups,
family.total_duplicated_lines,
&family.suggestions,
);
Self {
files: family.files,
groups: family
.groups
.into_iter()
.map(CloneGroupFinding::with_actions)
.collect(),
total_duplicated_lines: family.total_duplicated_lines,
total_duplicated_tokens: family.total_duplicated_tokens,
suggestions: family.suggestions,
actions,
}
}
}
fn build_clone_family_actions(
groups: &[CloneGroup],
total_duplicated_lines: usize,
suggestions: &[RefactoringSuggestion],
) -> Vec<CloneFamilyAction> {
let group_count = groups.len();
let mut actions = Vec::with_capacity(2 + suggestions.len());
actions.push(CloneFamilyAction {
kind: CloneFamilyActionType::ExtractShared,
auto_fixable: false,
description: format!(
"Extract {group_count} duplicated code block{} ({total_duplicated_lines} lines) into a shared module",
if group_count == 1 { "" } else { "s" },
),
note: Some(
"These clone groups share the same files, indicating a structural relationship; refactor together"
.to_string(),
),
comment: None,
});
for suggestion in suggestions {
actions.push(CloneFamilyAction {
kind: CloneFamilyActionType::ApplySuggestion,
auto_fixable: false,
description: suggestion.description.clone(),
note: None,
comment: None,
});
}
actions.push(CloneFamilyAction {
kind: CloneFamilyActionType::SuppressLine,
auto_fixable: false,
description: SUPPRESS_DESCRIPTION.to_string(),
note: None,
comment: Some(SUPPRESS_COMMENT.to_string()),
});
actions
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AttributedCloneGroupFinding {
#[serde(flatten)]
pub group: AttributedCloneGroup,
pub actions: Vec<CloneGroupAction>,
}
impl AttributedCloneGroupFinding {
#[must_use]
pub fn with_actions(group: AttributedCloneGroup) -> Self {
let line_count = group.line_count;
let instance_count = group.instances.len();
let actions = vec![
CloneGroupAction {
kind: CloneGroupActionType::ExtractShared,
auto_fixable: false,
description: format!(
"Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
if instance_count == 1 { "" } else { "s" },
),
comment: None,
},
CloneGroupAction {
kind: CloneGroupActionType::SuppressLine,
auto_fixable: false,
description: SUPPRESS_DESCRIPTION.to_string(),
comment: Some(SUPPRESS_COMMENT.to_string()),
},
];
Self { group, actions }
}
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DupesReportPayload {
pub clone_groups: Vec<CloneGroupFinding>,
pub clone_families: Vec<CloneFamilyFinding>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mirrored_directories: Vec<MirroredDirectory>,
pub stats: DuplicationStats,
}
impl DupesReportPayload {
#[must_use]
pub fn from_report(report: &DuplicationReport) -> Self {
Self {
clone_groups: report
.clone_groups
.iter()
.cloned()
.map(CloneGroupFinding::with_actions)
.collect(),
clone_families: report
.clone_families
.iter()
.cloned()
.map(CloneFamilyFinding::with_actions)
.collect(),
mirrored_directories: report.mirrored_directories.clone(),
stats: report.stats.clone(),
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fallow_core::duplicates::{
CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
};
use super::*;
fn instance(path: &str) -> CloneInstance {
CloneInstance {
file: PathBuf::from(path),
start_line: 1,
end_line: 10,
start_col: 0,
end_col: 0,
fragment: String::new(),
}
}
fn group(instances: usize) -> CloneGroup {
CloneGroup {
instances: (0..instances)
.map(|i| instance(&format!("/root/file_{i}.ts")))
.collect(),
token_count: 100,
line_count: 20,
}
}
#[test]
fn clone_group_finding_position_0_is_extract_shared() {
let finding = CloneGroupFinding::with_actions(group(2));
assert_eq!(finding.actions.len(), 2);
assert_eq!(
finding.actions[0].kind,
CloneGroupActionType::ExtractShared,
"position 0 of a clone group must be `extract-shared` (jq scripts read .actions[0].type)",
);
assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
assert!(finding.introduced.is_none());
}
#[test]
fn clone_group_finding_description_pluralises_instance_count() {
let single = CloneGroupFinding::with_actions(group(1));
assert!(
single.actions[0].description.contains("1 instance"),
"single instance should be singular: {}",
single.actions[0].description
);
assert!(
!single.actions[0].description.contains("1 instances"),
"single instance must not pluralise: {}",
single.actions[0].description
);
let multi = CloneGroupFinding::with_actions(group(3));
assert!(
multi.actions[0].description.contains("3 instances"),
"multiple instances must pluralise: {}",
multi.actions[0].description
);
}
#[test]
fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
let family = CloneFamily {
files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
groups: vec![group(2), group(2)],
total_duplicated_lines: 40,
total_duplicated_tokens: 200,
suggestions: vec![
RefactoringSuggestion {
kind: RefactoringKind::ExtractFunction,
description: "Extract helper".to_string(),
estimated_savings: 10,
},
RefactoringSuggestion {
kind: RefactoringKind::ExtractModule,
description: "Extract module".to_string(),
estimated_savings: 30,
},
],
};
let finding = CloneFamilyFinding::with_actions(family);
assert_eq!(finding.actions.len(), 4);
assert_eq!(
finding.actions[0].kind,
CloneFamilyActionType::ExtractShared,
"position 0 of a clone family must be `extract-shared`",
);
assert_eq!(
finding.actions[1].kind,
CloneFamilyActionType::ApplySuggestion
);
assert_eq!(finding.actions[1].description, "Extract helper");
assert_eq!(
finding.actions[2].kind,
CloneFamilyActionType::ApplySuggestion
);
assert_eq!(finding.actions[2].description, "Extract module");
assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
assert_eq!(finding.groups.len(), 2);
for inner in &finding.groups {
assert_eq!(inner.actions.len(), 2);
assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
}
}
#[test]
fn clone_family_finding_with_no_suggestions_emits_two_actions() {
let family = CloneFamily {
files: vec![PathBuf::from("/root/a.ts")],
groups: vec![group(2)],
total_duplicated_lines: 20,
total_duplicated_tokens: 100,
suggestions: Vec::new(),
};
let finding = CloneFamilyFinding::with_actions(family);
assert_eq!(finding.actions.len(), 2);
assert_eq!(
finding.actions[0].kind,
CloneFamilyActionType::ExtractShared
);
assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
}
#[test]
fn payload_from_report_wraps_all_findings() {
let report = DuplicationReport {
clone_groups: vec![group(2), group(3)],
clone_families: vec![CloneFamily {
files: vec![PathBuf::from("/root/a.ts")],
groups: vec![group(2)],
total_duplicated_lines: 20,
total_duplicated_tokens: 100,
suggestions: Vec::new(),
}],
mirrored_directories: Vec::new(),
stats: DuplicationStats::default(),
};
let payload = DupesReportPayload::from_report(&report);
assert_eq!(payload.clone_groups.len(), 2);
assert_eq!(payload.clone_families.len(), 1);
for finding in &payload.clone_groups {
assert_eq!(finding.actions.len(), 2);
}
assert_eq!(payload.clone_families[0].actions.len(), 2);
}
#[test]
fn attributed_clone_group_finding_actions_match_clone_group_shape() {
use crate::report::dupes_grouping::AttributedInstance;
let attributed = AttributedCloneGroup {
primary_owner: "src".to_string(),
token_count: 100,
line_count: 20,
instances: vec![
AttributedInstance {
instance: instance("/root/src/a.ts"),
owner: "src".to_string(),
},
AttributedInstance {
instance: instance("/root/src/b.ts"),
owner: "src".to_string(),
},
],
};
let finding = AttributedCloneGroupFinding::with_actions(attributed);
assert_eq!(finding.actions.len(), 2);
assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
}
}