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::{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(default, skip_serializing_if = "Option::is_none")]
83 pub baseline_staleness: Option<crate::BaselineStaleness>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub gate_outcomes: Option<crate::GateOutcomes>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub request_outcomes: Option<crate::RequestOutcomes>,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub package_baselines: Vec<crate::PackageBaselineStatus>,
104 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
107 pub meta: Option<Meta>,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
117 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub next_steps: Vec<NextStep>,
121}
122
123#[derive(Debug, Clone)]
126pub struct DupesOutputInput<Report, Group> {
127 pub schema_version: u32,
129 pub version: String,
131 pub elapsed: Duration,
133 pub report: Report,
135 pub clone_groups_shown: usize,
137 pub clone_groups_omitted: usize,
139 pub clone_families_shown: usize,
141 pub clone_families_omitted: usize,
143 pub grouped_by: Option<GroupByMode>,
145 pub total_issues: Option<usize>,
147 pub groups: Option<Vec<Group>>,
149 pub baseline_staleness: Option<crate::BaselineStaleness>,
151 pub gate_outcomes: Option<crate::GateOutcomes>,
153 pub request_outcomes: Option<crate::RequestOutcomes>,
156 pub meta: Option<Meta>,
158 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
161 pub next_steps: Vec<NextStep>,
163}
164
165#[must_use]
167pub fn build_dupes_output<Report, Group>(
168 input: DupesOutputInput<Report, Group>,
169) -> DupesOutput<Report, Group> {
170 DupesOutput {
171 schema_version: SchemaVersion(input.schema_version),
172 version: ToolVersion(input.version),
173 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
174 report: input.report,
175 clone_groups_shown: input.clone_groups_shown,
176 clone_groups_omitted: input.clone_groups_omitted,
177 clone_families_shown: input.clone_families_shown,
178 clone_families_omitted: input.clone_families_omitted,
179 grouped_by: input.grouped_by,
180 total_issues: input.total_issues,
181 groups: input.groups,
182 baseline_staleness: input.baseline_staleness,
183 gate_outcomes: input.gate_outcomes,
184 request_outcomes: input.request_outcomes,
185 package_baselines: Vec::new(),
186 meta: input.meta,
187 workspace_diagnostics: input.workspace_diagnostics,
188 next_steps: input.next_steps,
189 }
190}
191
192pub fn serialize_dupes_json_output<Report, Group>(
199 output: DupesOutput<Report, Group>,
200 analysis_run_id: Option<&str>,
201) -> Result<serde_json::Value, serde_json::Error>
202where
203 Report: Serialize,
204 Group: Serialize,
205{
206 let mut value = serialize_named_json_output(output, "dupes")?;
207 attach_telemetry_meta(&mut value, analysis_run_id);
208 Ok(value)
209}
210
211pub const DUPES_SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";
213
214pub const DUPES_SUPPRESS_DESCRIPTION: &str =
216 "Suppress with an inline comment above the duplicated code";
217
218#[derive(Debug, Clone, Serialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224pub struct CloneGroupAction {
225 #[serde(rename = "type")]
227 pub kind: CloneGroupActionType,
228 pub auto_fixable: bool,
232 pub description: String,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub comment: Option<String>,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
244#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
245#[serde(rename_all = "kebab-case")]
246pub enum CloneGroupActionType {
247 ExtractShared,
249 SuppressLine,
251}
252
253#[derive(Debug, Clone, Serialize)]
259#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
260pub struct CloneFamilyAction {
261 #[serde(rename = "type")]
263 pub kind: CloneFamilyActionType,
264 pub auto_fixable: bool,
267 pub description: String,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub note: Option<String>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub comment: Option<String>,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
282#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
283#[serde(rename_all = "kebab-case")]
284pub enum CloneFamilyActionType {
285 ExtractShared,
287 ApplySuggestion,
289 SuppressLine,
291}
292
293#[must_use]
295pub fn clone_group_actions(line_count: usize, instance_count: usize) -> Vec<CloneGroupAction> {
296 vec![
297 CloneGroupAction {
298 kind: CloneGroupActionType::ExtractShared,
299 auto_fixable: false,
300 description: format!(
301 "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
302 if instance_count == 1 { "" } else { "s" },
303 ),
304 comment: None,
305 },
306 CloneGroupAction {
307 kind: CloneGroupActionType::SuppressLine,
308 auto_fixable: false,
309 description: DUPES_SUPPRESS_DESCRIPTION.to_string(),
310 comment: Some(DUPES_SUPPRESS_COMMENT.to_string()),
311 },
312 ]
313}
314
315#[must_use]
317pub fn clone_family_actions<'a>(
318 group_count: usize,
319 total_duplicated_lines: usize,
320 suggestion_descriptions: impl IntoIterator<Item = &'a str>,
321) -> Vec<CloneFamilyAction> {
322 let suggestions = suggestion_descriptions.into_iter();
323 let (lower, _) = suggestions.size_hint();
324 let mut actions = Vec::with_capacity(2 + lower);
325 actions.push(CloneFamilyAction {
326 kind: CloneFamilyActionType::ExtractShared,
327 auto_fixable: false,
328 description: format!(
329 "Extract {group_count} duplicated code block{} ({total_duplicated_lines} lines) into a shared module",
330 if group_count == 1 { "" } else { "s" },
331 ),
332 note: Some(
333 "These clone groups share the same files, indicating a structural relationship; refactor together"
334 .to_string(),
335 ),
336 comment: None,
337 });
338 for description in suggestions {
339 actions.push(CloneFamilyAction {
340 kind: CloneFamilyActionType::ApplySuggestion,
341 auto_fixable: false,
342 description: description.to_string(),
343 note: None,
344 comment: None,
345 });
346 }
347 actions.push(CloneFamilyAction {
348 kind: CloneFamilyActionType::SuppressLine,
349 auto_fixable: false,
350 description: DUPES_SUPPRESS_DESCRIPTION.to_string(),
351 note: None,
352 comment: Some(DUPES_SUPPRESS_COMMENT.to_string()),
353 });
354 actions
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use serde_json::json;
361
362 #[test]
363 fn dupes_json_output_uses_output_owned_root_contract() {
364 let output = build_dupes_output(DupesOutputInput::<_, serde_json::Value> {
365 gate_outcomes: None,
366 request_outcomes: None,
367 baseline_staleness: None,
368 schema_version: 7,
369 version: "0.0.0".to_string(),
370 elapsed: Duration::from_millis(5),
371 report: json!({"stats": {"clone_groups": 0}}),
372 clone_groups_shown: 0,
373 clone_groups_omitted: 0,
374 clone_families_shown: 0,
375 clone_families_omitted: 0,
376 grouped_by: None,
377 total_issues: None,
378 groups: None,
379 meta: None,
380 workspace_diagnostics: Vec::new(),
381 next_steps: Vec::new(),
382 });
383
384 let value = serialize_dupes_json_output(output, Some("run-dupes"))
385 .expect("dupes output should serialize");
386
387 assert_eq!(value["kind"], "dupes");
388 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-dupes");
389 }
390
391 #[test]
392 fn clone_group_actions_keep_primary_then_suppression_order() {
393 let actions = clone_group_actions(20, 2);
394 assert_eq!(actions[0].kind, CloneGroupActionType::ExtractShared);
395 assert_eq!(actions[1].kind, CloneGroupActionType::SuppressLine);
396 assert_eq!(actions[1].comment.as_deref(), Some(DUPES_SUPPRESS_COMMENT));
397 }
398
399 #[test]
400 fn clone_family_actions_insert_suggestions_between_primary_and_suppression() {
401 let actions = clone_family_actions(2, 40, ["Move to shared parser"]);
402 assert_eq!(actions[0].kind, CloneFamilyActionType::ExtractShared);
403 assert_eq!(actions[1].kind, CloneFamilyActionType::ApplySuggestion);
404 assert_eq!(actions[1].description, "Move to shared parser");
405 assert_eq!(actions[2].kind, CloneFamilyActionType::SuppressLine);
406 }
407}