1use std::time::Duration;
8
9use fallow_types::envelope::{ElapsedMs, Meta, SchemaVersion, ToolVersion};
10use fallow_types::output::NextStep;
11use fallow_types::workspace::WorkspaceDiagnostic;
12use serde::Serialize;
13
14use crate::GroupByMode;
15use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
16
17#[derive(Debug, Clone, Serialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24#[cfg_attr(feature = "schema", schemars(title = "fallow dupes --format json"))]
25pub struct DupesOutput<Report, Group> {
26 pub schema_version: SchemaVersion,
27 pub version: ToolVersion,
28 pub elapsed_ms: ElapsedMs,
29 #[serde(flatten)]
30 pub report: Report,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub grouped_by: Option<GroupByMode>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub total_issues: Option<usize>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub groups: Option<Vec<Group>>,
37 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
40 pub meta: Option<Meta>,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub next_steps: Vec<NextStep>,
52}
53
54#[derive(Debug, Clone)]
57pub struct DupesOutputInput<Report, Group> {
58 pub schema_version: u32,
59 pub version: String,
60 pub elapsed: Duration,
61 pub report: Report,
62 pub grouped_by: Option<GroupByMode>,
63 pub total_issues: Option<usize>,
64 pub groups: Option<Vec<Group>>,
65 pub meta: Option<Meta>,
66 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
67 pub next_steps: Vec<NextStep>,
68}
69
70#[must_use]
72pub fn build_dupes_output<Report, Group>(
73 input: DupesOutputInput<Report, Group>,
74) -> DupesOutput<Report, Group> {
75 DupesOutput {
76 schema_version: SchemaVersion(input.schema_version),
77 version: ToolVersion(input.version),
78 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
79 report: input.report,
80 grouped_by: input.grouped_by,
81 total_issues: input.total_issues,
82 groups: input.groups,
83 meta: input.meta,
84 workspace_diagnostics: input.workspace_diagnostics,
85 next_steps: input.next_steps,
86 }
87}
88
89pub fn serialize_dupes_json_output<Report, Group>(
96 output: DupesOutput<Report, Group>,
97 mode: RootEnvelopeMode,
98 analysis_run_id: Option<&str>,
99) -> Result<serde_json::Value, serde_json::Error>
100where
101 Report: Serialize,
102 Group: Serialize,
103{
104 let mut value = serialize_named_json_output(output, "dupes", mode)?;
105 attach_telemetry_meta(&mut value, analysis_run_id);
106 Ok(value)
107}
108
109pub const DUPES_SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";
111
112pub const DUPES_SUPPRESS_DESCRIPTION: &str =
114 "Suppress with an inline comment above the duplicated code";
115
116#[derive(Debug, Clone, Serialize)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122pub struct CloneGroupAction {
123 #[serde(rename = "type")]
125 pub kind: CloneGroupActionType,
126 pub auto_fixable: bool,
130 pub description: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub comment: Option<String>,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
142#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
143#[serde(rename_all = "kebab-case")]
144pub enum CloneGroupActionType {
145 ExtractShared,
147 SuppressLine,
149}
150
151#[derive(Debug, Clone, Serialize)]
157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
158pub struct CloneFamilyAction {
159 #[serde(rename = "type")]
161 pub kind: CloneFamilyActionType,
162 pub auto_fixable: bool,
165 pub description: String,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub note: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub comment: Option<String>,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
181#[serde(rename_all = "kebab-case")]
182pub enum CloneFamilyActionType {
183 ExtractShared,
185 ApplySuggestion,
187 SuppressLine,
189}
190
191#[must_use]
193pub fn clone_group_actions(line_count: usize, instance_count: usize) -> Vec<CloneGroupAction> {
194 vec![
195 CloneGroupAction {
196 kind: CloneGroupActionType::ExtractShared,
197 auto_fixable: false,
198 description: format!(
199 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
200 if instance_count == 1 { "" } else { "s" },
201 ),
202 comment: None,
203 },
204 CloneGroupAction {
205 kind: CloneGroupActionType::SuppressLine,
206 auto_fixable: false,
207 description: DUPES_SUPPRESS_DESCRIPTION.to_string(),
208 comment: Some(DUPES_SUPPRESS_COMMENT.to_string()),
209 },
210 ]
211}
212
213#[must_use]
215pub fn clone_family_actions<'a>(
216 group_count: usize,
217 total_duplicated_lines: usize,
218 suggestion_descriptions: impl IntoIterator<Item = &'a str>,
219) -> Vec<CloneFamilyAction> {
220 let suggestions = suggestion_descriptions.into_iter();
221 let (lower, _) = suggestions.size_hint();
222 let mut actions = Vec::with_capacity(2 + lower);
223 actions.push(CloneFamilyAction {
224 kind: CloneFamilyActionType::ExtractShared,
225 auto_fixable: false,
226 description: format!(
227 "Extract {group_count} duplicated code block{} ({total_duplicated_lines} lines) into a shared module",
228 if group_count == 1 { "" } else { "s" },
229 ),
230 note: Some(
231 "These clone groups share the same files, indicating a structural relationship; refactor together"
232 .to_string(),
233 ),
234 comment: None,
235 });
236 for description in suggestions {
237 actions.push(CloneFamilyAction {
238 kind: CloneFamilyActionType::ApplySuggestion,
239 auto_fixable: false,
240 description: description.to_string(),
241 note: None,
242 comment: None,
243 });
244 }
245 actions.push(CloneFamilyAction {
246 kind: CloneFamilyActionType::SuppressLine,
247 auto_fixable: false,
248 description: DUPES_SUPPRESS_DESCRIPTION.to_string(),
249 note: None,
250 comment: Some(DUPES_SUPPRESS_COMMENT.to_string()),
251 });
252 actions
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use serde_json::json;
259
260 #[test]
261 fn dupes_json_output_uses_output_owned_root_contract() {
262 let output = build_dupes_output(DupesOutputInput::<_, serde_json::Value> {
263 schema_version: 7,
264 version: "0.0.0".to_string(),
265 elapsed: Duration::from_millis(5),
266 report: json!({"stats": {"clone_groups": 0}}),
267 grouped_by: None,
268 total_issues: None,
269 groups: None,
270 meta: None,
271 workspace_diagnostics: Vec::new(),
272 next_steps: Vec::new(),
273 });
274
275 let value =
276 serialize_dupes_json_output(output, RootEnvelopeMode::Tagged, Some("run-dupes"))
277 .expect("dupes output should serialize");
278
279 assert_eq!(value["kind"], "dupes");
280 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-dupes");
281 }
282
283 #[test]
284 fn clone_group_actions_keep_primary_then_suppression_order() {
285 let actions = clone_group_actions(20, 2);
286 assert_eq!(actions[0].kind, CloneGroupActionType::ExtractShared);
287 assert_eq!(actions[1].kind, CloneGroupActionType::SuppressLine);
288 assert_eq!(actions[1].comment.as_deref(), Some(DUPES_SUPPRESS_COMMENT));
289 }
290
291 #[test]
292 fn clone_family_actions_insert_suggestions_between_primary_and_suppression() {
293 let actions = clone_family_actions(2, 40, ["Move to shared parser"]);
294 assert_eq!(actions[0].kind, CloneFamilyActionType::ExtractShared);
295 assert_eq!(actions[1].kind, CloneFamilyActionType::ApplySuggestion);
296 assert_eq!(actions[1].description, "Move to shared parser");
297 assert_eq!(actions[2].kind, CloneFamilyActionType::SuppressLine);
298 }
299}