Skip to main content

fallow_output/
dupes.rs

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