Skip to main content

fallow_output/
dupes.rs

1//! Shared output contracts for duplication action arrays.
2//!
3//! The duplication report body still lives close to the CLI renderer because
4//! it wraps clone types owned by `fallow-core`. These action DTOs are
5//! core-independent and are shared by CLI schema emission, JSON output, and
6//! future API/LSP consumers.
7
8use 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/// Envelope emitted by `fallow dupes --format json`.
19///
20/// `Report` and `Group` are generic so the envelope can live in
21/// `fallow-output` while duplication report wrappers and grouped output
22/// internals continue to migrate out of CLI/API-specific crates.
23#[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    /// `_meta` block with metric / rule definitions, emitted when `--explain`
39    /// is passed (always present in MCP responses).
40    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
41    pub meta: Option<Meta>,
42    /// Workspace-discovery diagnostics surfaced during config load
43    /// (issue #473). See `CheckOutput::workspace_diagnostics` for the full
44    /// contract; the same list is repeated on each top-level command's
45    /// envelope so single-command consumers see it without having to look at
46    /// a separate top-level field.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
49    /// Read-only follow-up commands computed from this run's findings. See
50    /// `CheckOutput::next_steps` for the contract.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub next_steps: Vec<NextStep>,
53}
54
55/// Inputs for constructing a [`DupesOutput`] without exposing envelope assembly
56/// details to callers.
57#[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/// Build a duplication JSON envelope from caller-owned report data.
72#[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
90/// Serialize `fallow dupes --format json`.
91///
92/// # Errors
93///
94/// Returns a serde error when the duplication output cannot be converted to
95/// JSON.
96pub 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
110/// Inline suppression comment emitted for code duplication findings.
111pub const DUPES_SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";
112
113/// Shared description for the suppression action emitted on duplication findings.
114pub const DUPES_SUPPRESS_DESCRIPTION: &str =
115    "Suppress with an inline comment above the duplicated code";
116
117/// Per-action wire shape attached to each `CloneGroupFinding` and
118/// `AttributedCloneGroupFinding`. Mirrors the action types previously
119/// emitted by `inject_dupes_actions::build_clone_group_actions` in
120/// `crates/cli/src/report/json.rs`: `extract-shared` plus `suppress-line`.
121#[derive(Debug, Clone, Serialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct CloneGroupAction {
124    /// Action type identifier.
125    #[serde(rename = "type")]
126    pub kind: CloneGroupActionType,
127    /// Whether `fallow fix` can auto-apply this action. Both variants are
128    /// manual today; the field is non-singleton so a future auto-applier
129    /// does not need a schema change.
130    pub auto_fixable: bool,
131    /// Human-readable description of the action.
132    pub description: String,
133    /// The inline comment to insert (e.g.,
134    /// `// fallow-ignore-next-line code-duplication`). Present on
135    /// `suppress-line`; absent on `extract-shared`.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub comment: Option<String>,
138}
139
140/// Discriminant for [`CloneGroupAction::kind`]. Mirrors the action types
141/// emitted by the legacy `build_clone_group_actions` walker.
142#[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    /// Extract the duplicated code into a shared function.
147    ExtractShared,
148    /// Suppress the finding with an inline comment above the duplicated code.
149    SuppressLine,
150}
151
152/// Per-action wire shape attached to each `CloneFamilyFinding`. Mirrors
153/// the action types previously emitted by
154/// `build_clone_family_actions`: `extract-shared`, one `apply-suggestion`
155/// per `RefactoringSuggestion` on the family, and a trailing
156/// `suppress-line`.
157#[derive(Debug, Clone, Serialize)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159pub struct CloneFamilyAction {
160    /// Action type identifier.
161    #[serde(rename = "type")]
162    pub kind: CloneFamilyActionType,
163    /// Whether `fallow fix` can auto-apply this action. All three variants
164    /// are manual today.
165    pub auto_fixable: bool,
166    /// Human-readable description of the action.
167    pub description: String,
168    /// Additional context. Present on `extract-shared` (explaining that
169    /// the family's clone groups share the same files); absent otherwise.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub note: Option<String>,
172    /// The inline comment to insert (e.g.,
173    /// `// fallow-ignore-next-line code-duplication`). Present on
174    /// `suppress-line` only.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub comment: Option<String>,
177}
178
179/// Discriminant for [`CloneFamilyAction::kind`].
180#[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    /// Extract the duplicated code blocks into a shared module.
185    ExtractShared,
186    /// Apply one of the family's refactoring suggestions.
187    ApplySuggestion,
188    /// Suppress with an inline comment above the duplicated code.
189    SuppressLine,
190}
191
192/// Build the stable action list for one clone group.
193#[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/// Build the stable action list for a clone family.
215#[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}