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/// Current schema version for `fallow dupes --format json`.
18pub const DUPES_SCHEMA_VERSION: u32 = 10;
19
20/// Current schema version for programmatic duplication JSON.
21pub const DUPES_PROGRAMMATIC_SCHEMA_VERSION: u32 = 4;
22
23/// Schema projection for the duplication envelope's CLI and programmatic
24/// version lineages.
25#[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/// Envelope emitted by `fallow dupes --format json`.
34///
35/// `Report` and `Group` are generic so the envelope can live in
36/// `fallow-output` while duplication report wrappers and grouped output
37/// internals continue to migrate out of CLI/API-specific crates.
38#[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    /// Duplication output schema version.
43    #[cfg_attr(feature = "schema", schemars(with = "DupesSchemaVersion"))]
44    pub schema_version: SchemaVersion,
45    /// Fallow CLI version that produced this output.
46    pub version: ToolVersion,
47    /// Wall-clock analysis duration in milliseconds.
48    pub elapsed_ms: ElapsedMs,
49    /// Duplication report body, flattened into the envelope root.
50    #[serde(flatten)]
51    pub report: Report,
52    /// Number of clone groups carried in `clone_groups[]`.
53    pub clone_groups_shown: usize,
54    /// Number of scoped-corpus clone groups withheld from `clone_groups[]` by
55    /// a presentation cap such as `--top`. `0` on an untruncated run, so
56    /// `clone_groups_shown + clone_groups_omitted == stats.clone_groups`
57    /// always holds and `stats` keeps describing the whole measured corpus.
58    pub clone_groups_omitted: usize,
59    /// Number of clone families carried in `clone_families[]`.
60    pub clone_families_shown: usize,
61    /// Number of scoped-corpus clone families withheld from `clone_families[]`
62    /// by a presentation cap such as `--top`, which rebuilds the families from
63    /// the groups that survived the cap. `0` on an untruncated run, so
64    /// `clone_families_shown + clone_families_omitted == stats.clone_families`
65    /// always holds and `stats` keeps describing the whole measured corpus.
66    pub clone_families_omitted: usize,
67    /// Grouping mode when `--group-by` was passed.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub grouped_by: Option<GroupByMode>,
70    /// Total finding count across all groups; present only in grouped output.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub total_issues: Option<usize>,
73    /// Grouped findings; present only in grouped output.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub groups: Option<Vec<Group>>,
76    /// `_meta` block with metric / rule definitions, emitted when `--explain`
77    /// is passed (always present in MCP responses).
78    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
79    pub meta: Option<Meta>,
80    /// Workspace-discovery and source-discovery diagnostics for the run
81    /// (issue #473). See `CheckOutput::workspace_diagnostics` for the full
82    /// contract; the same list is repeated on each top-level command's
83    /// envelope so single-command consumers see it without having to look at
84    /// a separate top-level field. A standalone `fallow dupes` run has no
85    /// dead-code analyze pass, so the two analysis-stage kinds never appear
86    /// here.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
89    /// Read-only follow-up commands computed from this run's findings. See
90    /// `CheckOutput::next_steps` for the contract.
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub next_steps: Vec<NextStep>,
93}
94
95/// Inputs for constructing a [`DupesOutput`] without exposing envelope assembly
96/// details to callers.
97#[derive(Debug, Clone)]
98pub struct DupesOutputInput<Report, Group> {
99    /// Duplication output schema version.
100    pub schema_version: u32,
101    /// Fallow CLI version to report.
102    pub version: String,
103    /// Wall-clock analysis duration; serialized as whole milliseconds.
104    pub elapsed: Duration,
105    /// Duplication report body to flatten into the envelope root.
106    pub report: Report,
107    /// Number of clone groups carried in `clone_groups[]`.
108    pub clone_groups_shown: usize,
109    /// Number of scoped-corpus clone groups withheld by a presentation cap.
110    pub clone_groups_omitted: usize,
111    /// Number of clone families carried in `clone_families[]`.
112    pub clone_families_shown: usize,
113    /// Number of scoped-corpus clone families withheld by a presentation cap.
114    pub clone_families_omitted: usize,
115    /// Grouping mode when `--group-by` was passed.
116    pub grouped_by: Option<GroupByMode>,
117    /// Total finding count across all groups, for grouped output.
118    pub total_issues: Option<usize>,
119    /// Grouped findings, for grouped output.
120    pub groups: Option<Vec<Group>>,
121    /// `_meta` block to attach when `--explain` was passed.
122    pub meta: Option<Meta>,
123    /// Workspace-discovery and source-discovery diagnostics. See
124    /// `CheckOutput::workspace_diagnostics` for the contract.
125    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
126    /// Read-only follow-up commands computed from this run's findings.
127    pub next_steps: Vec<NextStep>,
128}
129
130/// Build a duplication JSON envelope from caller-owned report data.
131#[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
153/// Serialize `fallow dupes --format json`.
154///
155/// # Errors
156///
157/// Returns a serde error when the duplication output cannot be converted to
158/// JSON.
159pub 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
173/// Inline suppression comment emitted for code duplication findings.
174pub const DUPES_SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";
175
176/// Shared description for the suppression action emitted on duplication findings.
177pub const DUPES_SUPPRESS_DESCRIPTION: &str =
178    "Suppress with an inline comment above the duplicated code";
179
180/// Per-action wire shape attached to each `CloneGroupFinding` and
181/// `AttributedCloneGroupFinding` (see `crates/api/src/dupes_output.rs`):
182/// `extract-shared` plus `suppress-line`. The typed wrappers replaced the
183/// legacy JSON post-pass injection that used to live in the CLI report layer.
184#[derive(Debug, Clone, Serialize)]
185#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
186pub struct CloneGroupAction {
187    /// Action type identifier.
188    #[serde(rename = "type")]
189    pub kind: CloneGroupActionType,
190    /// Whether `fallow fix` can auto-apply this action. Both variants are
191    /// manual today; the field is non-singleton so a future auto-applier
192    /// does not need a schema change.
193    pub auto_fixable: bool,
194    /// Human-readable description of the action.
195    pub description: String,
196    /// The inline comment to insert (e.g.,
197    /// `// fallow-ignore-next-line code-duplication`). Present on
198    /// `suppress-line`; absent on `extract-shared`.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub comment: Option<String>,
201}
202
203/// Discriminant for [`CloneGroupAction::kind`]. Mirrors the action types
204/// emitted by the legacy `build_clone_group_actions` walker.
205#[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    /// Extract the duplicated code into a shared function.
210    ExtractShared,
211    /// Suppress the finding with an inline comment above the duplicated code.
212    SuppressLine,
213}
214
215/// Per-action wire shape attached to each `CloneFamilyFinding`. Mirrors
216/// the action types previously emitted by
217/// `build_clone_family_actions`: `extract-shared`, one `apply-suggestion`
218/// per `RefactoringSuggestion` on the family, and a trailing
219/// `suppress-line`.
220#[derive(Debug, Clone, Serialize)]
221#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
222pub struct CloneFamilyAction {
223    /// Action type identifier.
224    #[serde(rename = "type")]
225    pub kind: CloneFamilyActionType,
226    /// Whether `fallow fix` can auto-apply this action. All three variants
227    /// are manual today.
228    pub auto_fixable: bool,
229    /// Human-readable description of the action.
230    pub description: String,
231    /// Additional context. Present on `extract-shared` (explaining that
232    /// the family's clone groups share the same files); absent otherwise.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub note: Option<String>,
235    /// The inline comment to insert (e.g.,
236    /// `// fallow-ignore-next-line code-duplication`). Present on
237    /// `suppress-line` only.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub comment: Option<String>,
240}
241
242/// Discriminant for [`CloneFamilyAction::kind`].
243#[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    /// Extract the duplicated code blocks into a shared module.
248    ExtractShared,
249    /// Apply one of the family's refactoring suggestions.
250    ApplySuggestion,
251    /// Suppress with an inline comment above the duplicated code.
252    SuppressLine,
253}
254
255/// Build the stable action list for one clone group.
256#[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/// Build the stable action list for a clone family.
278#[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}