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