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
17pub const DUPES_SCHEMA_VERSION: u32 = 10;
19
20pub const DUPES_PROGRAMMATIC_SCHEMA_VERSION: u32 = 4;
22
23#[cfg(feature = "schema")]
26#[allow(dead_code, reason = "schema-only type used by the field projection")]
27#[derive(schemars::JsonSchema)]
28#[schemars(extend(
29 "enum" = [DUPES_PROGRAMMATIC_SCHEMA_VERSION, DUPES_SCHEMA_VERSION]
30))]
31struct DupesSchemaVersion(u32);
32
33#[derive(Debug, Clone, Serialize)]
39#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
40#[cfg_attr(feature = "schema", schemars(title = "fallow dupes --format json"))]
41pub struct DupesOutput<Report, Group> {
42 #[cfg_attr(feature = "schema", schemars(with = "DupesSchemaVersion"))]
44 pub schema_version: SchemaVersion,
45 pub version: ToolVersion,
47 pub elapsed_ms: ElapsedMs,
49 #[serde(flatten)]
51 pub report: Report,
52 pub clone_groups_shown: usize,
54 pub clone_groups_omitted: usize,
59 pub clone_families_shown: usize,
61 pub clone_families_omitted: usize,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub grouped_by: Option<GroupByMode>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub total_issues: Option<usize>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub groups: Option<Vec<Group>>,
76 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
79 pub meta: Option<Meta>,
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
92 pub next_steps: Vec<NextStep>,
93}
94
95#[derive(Debug, Clone)]
98pub struct DupesOutputInput<Report, Group> {
99 pub schema_version: u32,
101 pub version: String,
103 pub elapsed: Duration,
105 pub report: Report,
107 pub clone_groups_shown: usize,
109 pub clone_groups_omitted: usize,
111 pub clone_families_shown: usize,
113 pub clone_families_omitted: usize,
115 pub grouped_by: Option<GroupByMode>,
117 pub total_issues: Option<usize>,
119 pub groups: Option<Vec<Group>>,
121 pub meta: Option<Meta>,
123 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
126 pub next_steps: Vec<NextStep>,
128}
129
130#[must_use]
132pub fn build_dupes_output<Report, Group>(
133 input: DupesOutputInput<Report, Group>,
134) -> DupesOutput<Report, Group> {
135 DupesOutput {
136 schema_version: SchemaVersion(input.schema_version),
137 version: ToolVersion(input.version),
138 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
139 report: input.report,
140 clone_groups_shown: input.clone_groups_shown,
141 clone_groups_omitted: input.clone_groups_omitted,
142 clone_families_shown: input.clone_families_shown,
143 clone_families_omitted: input.clone_families_omitted,
144 grouped_by: input.grouped_by,
145 total_issues: input.total_issues,
146 groups: input.groups,
147 meta: input.meta,
148 workspace_diagnostics: input.workspace_diagnostics,
149 next_steps: input.next_steps,
150 }
151}
152
153pub fn serialize_dupes_json_output<Report, Group>(
160 output: DupesOutput<Report, Group>,
161 mode: RootEnvelopeMode,
162 analysis_run_id: Option<&str>,
163) -> Result<serde_json::Value, serde_json::Error>
164where
165 Report: Serialize,
166 Group: Serialize,
167{
168 let mut value = serialize_named_json_output(output, "dupes", mode)?;
169 attach_telemetry_meta(&mut value, analysis_run_id);
170 Ok(value)
171}
172
173pub const DUPES_SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";
175
176pub const DUPES_SUPPRESS_DESCRIPTION: &str =
178 "Suppress with an inline comment above the duplicated code";
179
180#[derive(Debug, Clone, Serialize)]
185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
186pub struct CloneGroupAction {
187 #[serde(rename = "type")]
189 pub kind: CloneGroupActionType,
190 pub auto_fixable: bool,
194 pub description: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub comment: Option<String>,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
206#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
207#[serde(rename_all = "kebab-case")]
208pub enum CloneGroupActionType {
209 ExtractShared,
211 SuppressLine,
213}
214
215#[derive(Debug, Clone, Serialize)]
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
222pub struct CloneFamilyAction {
223 #[serde(rename = "type")]
225 pub kind: CloneFamilyActionType,
226 pub auto_fixable: bool,
229 pub description: String,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub note: Option<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub comment: Option<String>,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
244#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
245#[serde(rename_all = "kebab-case")]
246pub enum CloneFamilyActionType {
247 ExtractShared,
249 ApplySuggestion,
251 SuppressLine,
253}
254
255#[must_use]
257pub fn clone_group_actions(line_count: usize, instance_count: usize) -> Vec<CloneGroupAction> {
258 vec![
259 CloneGroupAction {
260 kind: CloneGroupActionType::ExtractShared,
261 auto_fixable: false,
262 description: format!(
263 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
264 if instance_count == 1 { "" } else { "s" },
265 ),
266 comment: None,
267 },
268 CloneGroupAction {
269 kind: CloneGroupActionType::SuppressLine,
270 auto_fixable: false,
271 description: DUPES_SUPPRESS_DESCRIPTION.to_string(),
272 comment: Some(DUPES_SUPPRESS_COMMENT.to_string()),
273 },
274 ]
275}
276
277#[must_use]
279pub fn clone_family_actions<'a>(
280 group_count: usize,
281 total_duplicated_lines: usize,
282 suggestion_descriptions: impl IntoIterator<Item = &'a str>,
283) -> Vec<CloneFamilyAction> {
284 let suggestions = suggestion_descriptions.into_iter();
285 let (lower, _) = suggestions.size_hint();
286 let mut actions = Vec::with_capacity(2 + lower);
287 actions.push(CloneFamilyAction {
288 kind: CloneFamilyActionType::ExtractShared,
289 auto_fixable: false,
290 description: format!(
291 "Extract {group_count} duplicated code block{} ({total_duplicated_lines} lines) into a shared module",
292 if group_count == 1 { "" } else { "s" },
293 ),
294 note: Some(
295 "These clone groups share the same files, indicating a structural relationship; refactor together"
296 .to_string(),
297 ),
298 comment: None,
299 });
300 for description in suggestions {
301 actions.push(CloneFamilyAction {
302 kind: CloneFamilyActionType::ApplySuggestion,
303 auto_fixable: false,
304 description: description.to_string(),
305 note: None,
306 comment: None,
307 });
308 }
309 actions.push(CloneFamilyAction {
310 kind: CloneFamilyActionType::SuppressLine,
311 auto_fixable: false,
312 description: DUPES_SUPPRESS_DESCRIPTION.to_string(),
313 note: None,
314 comment: Some(DUPES_SUPPRESS_COMMENT.to_string()),
315 });
316 actions
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use serde_json::json;
323
324 #[test]
325 fn dupes_json_output_uses_output_owned_root_contract() {
326 let output = build_dupes_output(DupesOutputInput::<_, serde_json::Value> {
327 schema_version: 7,
328 version: "0.0.0".to_string(),
329 elapsed: Duration::from_millis(5),
330 report: json!({"stats": {"clone_groups": 0}}),
331 clone_groups_shown: 0,
332 clone_groups_omitted: 0,
333 clone_families_shown: 0,
334 clone_families_omitted: 0,
335 grouped_by: None,
336 total_issues: None,
337 groups: None,
338 meta: None,
339 workspace_diagnostics: Vec::new(),
340 next_steps: Vec::new(),
341 });
342
343 let value =
344 serialize_dupes_json_output(output, RootEnvelopeMode::Tagged, Some("run-dupes"))
345 .expect("dupes output should serialize");
346
347 assert_eq!(value["kind"], "dupes");
348 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-dupes");
349 }
350
351 #[test]
352 fn clone_group_actions_keep_primary_then_suppression_order() {
353 let actions = clone_group_actions(20, 2);
354 assert_eq!(actions[0].kind, CloneGroupActionType::ExtractShared);
355 assert_eq!(actions[1].kind, CloneGroupActionType::SuppressLine);
356 assert_eq!(actions[1].comment.as_deref(), Some(DUPES_SUPPRESS_COMMENT));
357 }
358
359 #[test]
360 fn clone_family_actions_insert_suggestions_between_primary_and_suppression() {
361 let actions = clone_family_actions(2, 40, ["Move to shared parser"]);
362 assert_eq!(actions[0].kind, CloneFamilyActionType::ExtractShared);
363 assert_eq!(actions[1].kind, CloneFamilyActionType::ApplySuggestion);
364 assert_eq!(actions[1].description, "Move to shared parser");
365 assert_eq!(actions[2].kind, CloneFamilyActionType::SuppressLine);
366 }
367}