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::{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    /// This run's view of the loaded baseline, present only in baseline runs.
77    /// Carries the staleness counts, the advisory verdict and `gate_trips`, the
78    /// same boolean `--fail-on-stale-baseline` exits on, so a CI integration
79    /// reads one field instead of restating the rule. Read `change_scoped`
80    /// before dividing `matched_entries` by `baseline_entries`: a narrowed run
81    /// can report `matched_entries: 0` on a healthy baseline.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub baseline_staleness: Option<crate::BaselineStaleness>,
84    /// The verdict of every gate this run armed, keyed by name, absent when
85    /// it armed none. `dupes` has no default exit rule: a run with no armed
86    /// gate always exits 0, so an absent object means that the run passed.
87    /// `--fail-on-issues` and `--ci` arm `duplication-findings`, which fails
88    /// on any clone group. A gate fails the build when `status` is `fail` AND
89    /// `enforced` is true. See [`crate::GateOutcomes`].
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub gate_outcomes: Option<crate::GateOutcomes>,
92    /// Every narrowing or shaping request this run RECEIVED, keyed by name,
93    /// absent when it was asked for nothing. An entry whose `status` is not
94    /// `applied` means the run could not do what it was asked and reported
95    /// something WIDER instead, so what follows is a valid report of a scope
96    /// nobody requested. Honoured requests are published too, with
97    /// `status: "applied"`, so an absent object means "nothing was asked for",
98    /// never "nothing failed". See [`crate::RequestOutcomes`].
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub request_outcomes: Option<crate::RequestOutcomes>,
101    /// Applied package Git refs, omitted outside package-baseline runs.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub package_baselines: Vec<crate::PackageBaselineStatus>,
104    /// `_meta` block with metric / rule definitions, emitted when `--explain`
105    /// is passed (always present in MCP responses).
106    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
107    pub meta: Option<Meta>,
108    /// Workspace-discovery and source-discovery diagnostics for the run
109    /// (issue #473). See `CheckOutput::workspace_diagnostics` for the full
110    /// contract; the same list is repeated on each top-level command's
111    /// envelope so single-command consumers see it without having to look at
112    /// a separate top-level field. A standalone `fallow dupes` run has no
113    /// dead-code analyze pass, so the two analysis-stage kinds never appear
114    /// here.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
117    /// Read-only follow-up commands computed from this run's findings. See
118    /// `CheckOutput::next_steps` for the contract.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub next_steps: Vec<NextStep>,
121}
122
123/// Inputs for constructing a [`DupesOutput`] without exposing envelope assembly
124/// details to callers.
125#[derive(Debug, Clone)]
126pub struct DupesOutputInput<Report, Group> {
127    /// Duplication output schema version.
128    pub schema_version: u32,
129    /// Fallow CLI version to report.
130    pub version: String,
131    /// Wall-clock analysis duration; serialized as whole milliseconds.
132    pub elapsed: Duration,
133    /// Duplication report body to flatten into the envelope root.
134    pub report: Report,
135    /// Number of clone groups carried in `clone_groups[]`.
136    pub clone_groups_shown: usize,
137    /// Number of scoped-corpus clone groups withheld by a presentation cap.
138    pub clone_groups_omitted: usize,
139    /// Number of clone families carried in `clone_families[]`.
140    pub clone_families_shown: usize,
141    /// Number of scoped-corpus clone families withheld by a presentation cap.
142    pub clone_families_omitted: usize,
143    /// Grouping mode when `--group-by` was passed.
144    pub grouped_by: Option<GroupByMode>,
145    /// Total finding count across all groups, for grouped output.
146    pub total_issues: Option<usize>,
147    /// Grouped findings, for grouped output.
148    pub groups: Option<Vec<Group>>,
149    /// This run's view of the loaded duplication baseline, for baseline runs.
150    pub baseline_staleness: Option<crate::BaselineStaleness>,
151    /// Every gate this run evaluated, absent when it evaluated none.
152    pub gate_outcomes: Option<crate::GateOutcomes>,
153    /// Every narrowing or shaping request this run received, absent when it
154    /// was asked for nothing.
155    pub request_outcomes: Option<crate::RequestOutcomes>,
156    /// `_meta` block to attach when `--explain` was passed.
157    pub meta: Option<Meta>,
158    /// Workspace-discovery and source-discovery diagnostics. See
159    /// `CheckOutput::workspace_diagnostics` for the contract.
160    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
161    /// Read-only follow-up commands computed from this run's findings.
162    pub next_steps: Vec<NextStep>,
163}
164
165/// Build a duplication JSON envelope from caller-owned report data.
166#[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
192/// Serialize `fallow dupes --format json`.
193///
194/// # Errors
195///
196/// Returns a serde error when the duplication output cannot be converted to
197/// JSON.
198pub 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
211/// Inline suppression comment emitted for code duplication findings.
212pub const DUPES_SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";
213
214/// Shared description for the suppression action emitted on duplication findings.
215pub const DUPES_SUPPRESS_DESCRIPTION: &str =
216    "Suppress with an inline comment above the duplicated code";
217
218/// Per-action wire shape attached to each `CloneGroupFinding` and
219/// `AttributedCloneGroupFinding` (see `crates/api/src/dupes_output.rs`):
220/// `extract-shared` plus `suppress-line`. The typed wrappers replaced the
221/// legacy JSON post-pass injection that used to live in the CLI report layer.
222#[derive(Debug, Clone, Serialize)]
223#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
224pub struct CloneGroupAction {
225    /// Action type identifier.
226    #[serde(rename = "type")]
227    pub kind: CloneGroupActionType,
228    /// Whether `fallow fix` can auto-apply this action. Both variants are
229    /// manual today; the field is non-singleton so a future auto-applier
230    /// does not need a schema change.
231    pub auto_fixable: bool,
232    /// Human-readable description of the action.
233    pub description: String,
234    /// The inline comment to insert (e.g.,
235    /// `// fallow-ignore-next-line code-duplication`). Present on
236    /// `suppress-line`; absent on `extract-shared`.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub comment: Option<String>,
239}
240
241/// Discriminant for [`CloneGroupAction::kind`]. Mirrors the action types
242/// emitted by the legacy `build_clone_group_actions` walker.
243#[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    /// Extract the duplicated code into a shared function.
248    ExtractShared,
249    /// Suppress the finding with an inline comment above the duplicated code.
250    SuppressLine,
251}
252
253/// Per-action wire shape attached to each `CloneFamilyFinding`. Mirrors
254/// the action types previously emitted by
255/// `build_clone_family_actions`: `extract-shared`, one `apply-suggestion`
256/// per `RefactoringSuggestion` on the family, and a trailing
257/// `suppress-line`.
258#[derive(Debug, Clone, Serialize)]
259#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
260pub struct CloneFamilyAction {
261    /// Action type identifier.
262    #[serde(rename = "type")]
263    pub kind: CloneFamilyActionType,
264    /// Whether `fallow fix` can auto-apply this action. All three variants
265    /// are manual today.
266    pub auto_fixable: bool,
267    /// Human-readable description of the action.
268    pub description: String,
269    /// Additional context. Present on `extract-shared` (explaining that
270    /// the family's clone groups share the same files); absent otherwise.
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub note: Option<String>,
273    /// The inline comment to insert (e.g.,
274    /// `// fallow-ignore-next-line code-duplication`). Present on
275    /// `suppress-line` only.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub comment: Option<String>,
278}
279
280/// Discriminant for [`CloneFamilyAction::kind`].
281#[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    /// Extract the duplicated code blocks into a shared module.
286    ExtractShared,
287    /// Apply one of the family's refactoring suggestions.
288    ApplySuggestion,
289    /// Suppress with an inline comment above the duplicated code.
290    SuppressLine,
291}
292
293/// Build the stable action list for one clone group.
294#[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/// Build the stable action list for a clone family.
316#[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}