Skip to main content

fallow_cli/
output_dupes.rs

1//! Typed envelope wrappers for the duplication findings emitted by `fallow
2//! dupes --format json` (and the `dupes` block inside `fallow` and `fallow
3//! audit`).
4//!
5//! Each wrapper flattens the bare finding via `#[serde(flatten)]` so the wire
6//! shape matches the previous `actions`-grafted output byte-for-byte.
7//! `actions` is populated at construction time via each wrapper's
8//! `with_actions` constructor and replaces the legacy `inject_dupes_actions`
9//! post-pass in `crates/cli/src/report/json.rs`. `introduced` on
10//! `CloneGroupFinding` carries the optional audit breadcrumb that
11//! `crates/cli/src/audit.rs::annotate_dupes_json` inserts into the JSON object
12//! via `map.insert`; the wrapper-level field stays `None` when serialized
13//! directly from Rust and is set by the audit pass only when the clone group
14//! was introduced relative to the merge-base.
15//!
16//! Lives in `fallow-cli` rather than `fallow-types` because `CloneFamily`,
17//! `CloneGroup`, and `MirroredDirectory` are defined in `fallow-core`
18//! (`crates/core/src/duplicates/types.rs`) and `AttributedCloneGroup` is
19//! defined in the CLI itself (`crates/cli/src/report/dupes_grouping.rs`);
20//! `fallow-types` is the lower-level crate that neither of those reach.
21
22use std::path::PathBuf;
23
24use fallow_core::duplicates::{
25    CloneFamily, CloneGroup, DuplicationReport, DuplicationStats, MirroredDirectory,
26    RefactoringSuggestion,
27};
28use fallow_types::envelope::AuditIntroduced;
29use fallow_types::serde_path;
30use serde::Serialize;
31
32use crate::report::dupes_grouping::AttributedCloneGroup;
33
34/// Per-action wire shape attached to each [`CloneGroupFinding`] and
35/// [`AttributedCloneGroupFinding`]. Mirrors the action types previously
36/// emitted by `inject_dupes_actions::build_clone_group_actions` in
37/// `crates/cli/src/report/json.rs`: `extract-shared` plus `suppress-line`.
38#[derive(Debug, Clone, Serialize)]
39#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
40pub struct CloneGroupAction {
41    /// Action type identifier.
42    #[serde(rename = "type")]
43    pub kind: CloneGroupActionType,
44    /// Whether `fallow fix` can auto-apply this action. Both variants are
45    /// manual today; the field is non-singleton so a future auto-applier
46    /// does not need a schema change.
47    pub auto_fixable: bool,
48    /// Human-readable description of the action.
49    pub description: String,
50    /// The inline comment to insert (e.g.,
51    /// `// fallow-ignore-next-line code-duplication`). Present on
52    /// `suppress-line`; absent on `extract-shared`.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub comment: Option<String>,
55}
56
57/// Discriminant for [`CloneGroupAction::kind`]. Mirrors the action types
58/// emitted by the legacy `build_clone_group_actions` walker.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[serde(rename_all = "kebab-case")]
62pub enum CloneGroupActionType {
63    /// Extract the duplicated code into a shared function.
64    ExtractShared,
65    /// Suppress the finding with an inline `// fallow-ignore-next-line
66    /// code-duplication` comment above the duplicated code.
67    SuppressLine,
68}
69
70/// Per-action wire shape attached to each [`CloneFamilyFinding`]. Mirrors
71/// the action types previously emitted by
72/// `build_clone_family_actions`: `extract-shared`, one `apply-suggestion`
73/// per [`RefactoringSuggestion`] on the family, and a trailing
74/// `suppress-line`.
75#[derive(Debug, Clone, Serialize)]
76#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77pub struct CloneFamilyAction {
78    /// Action type identifier.
79    #[serde(rename = "type")]
80    pub kind: CloneFamilyActionType,
81    /// Whether `fallow fix` can auto-apply this action. All three variants
82    /// are manual today.
83    pub auto_fixable: bool,
84    /// Human-readable description of the action.
85    pub description: String,
86    /// Additional context. Present on `extract-shared` (explaining that
87    /// the family's clone groups share the same files); absent otherwise.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub note: Option<String>,
90    /// The inline comment to insert (e.g.,
91    /// `// fallow-ignore-next-line code-duplication`). Present on
92    /// `suppress-line` only.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub comment: Option<String>,
95}
96
97/// Discriminant for [`CloneFamilyAction::kind`].
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
99#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
100#[serde(rename_all = "kebab-case")]
101pub enum CloneFamilyActionType {
102    /// Extract the duplicated code blocks into a shared module.
103    ExtractShared,
104    /// Apply one of the family's [`RefactoringSuggestion`]s. One action
105    /// per suggestion entry on the bare family.
106    ApplySuggestion,
107    /// Suppress with an inline `// fallow-ignore-next-line code-duplication`
108    /// comment above the duplicated code.
109    SuppressLine,
110}
111
112const SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";
113const SUPPRESS_DESCRIPTION: &str = "Suppress with an inline comment above the duplicated code";
114
115/// Wire-shape envelope for a [`CloneGroup`] finding. Flattens the bare
116/// group via `#[serde(flatten)]` and carries a typed `actions` array plus
117/// the optional audit-mode `introduced` flag. Replaces the legacy
118/// post-pass injection in `crates/cli/src/report/json.rs::inject_dupes_actions`.
119#[derive(Debug, Clone, Serialize)]
120#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
121pub struct CloneGroupFinding {
122    /// The underlying clone group.
123    #[serde(flatten)]
124    pub group: CloneGroup,
125    /// Suggested next steps: an `extract-shared` primary and a
126    /// `suppress-line` secondary. Always emitted (possibly empty for
127    /// forward-compat).
128    pub actions: Vec<CloneGroupAction>,
129    /// Set by the audit pass when this clone group is introduced relative
130    /// to the merge-base. `None` when serialized directly from Rust.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub introduced: Option<AuditIntroduced>,
133}
134
135impl CloneGroupFinding {
136    /// Build the wrapper from a raw [`CloneGroup`], computing the typed
137    /// `actions` array inline. `introduced` stays `None` and is set later
138    /// by `annotate_dupes_json` if the audit pass runs.
139    #[must_use]
140    pub fn with_actions(group: CloneGroup) -> Self {
141        let line_count = group.line_count;
142        let instance_count = group.instances.len();
143        let actions = vec![
144            CloneGroupAction {
145                kind: CloneGroupActionType::ExtractShared,
146                auto_fixable: false,
147                description: format!(
148                    "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
149                    if instance_count == 1 { "" } else { "s" },
150                ),
151                comment: None,
152            },
153            CloneGroupAction {
154                kind: CloneGroupActionType::SuppressLine,
155                auto_fixable: false,
156                description: SUPPRESS_DESCRIPTION.to_string(),
157                comment: Some(SUPPRESS_COMMENT.to_string()),
158            },
159        ];
160        Self {
161            group,
162            actions,
163            introduced: None,
164        }
165    }
166}
167
168/// Wire-shape envelope for a [`CloneFamily`] finding.
169///
170/// Unlike most `*Finding` wrappers this one is NOT `#[serde(flatten)]` over
171/// the bare [`CloneFamily`], because the family's nested
172/// `groups: Vec<CloneGroup>` field needs to carry the typed
173/// [`CloneGroupFinding`] wrapper too (so every nested clone group gets its
174/// own `actions[]` array, matching the legacy post-pass behavior; see issue
175/// #393 regression test). The wire shape stays byte-identical to the
176/// previous post-pass output. No `introduced` field because `fallow audit`
177/// attributes clone groups (not families) when running against a base ref.
178#[derive(Debug, Clone, Serialize)]
179#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
180pub struct CloneFamilyFinding {
181    /// The files involved in this family (sorted for stable output).
182    #[serde(serialize_with = "serde_path::serialize_vec")]
183    pub files: Vec<PathBuf>,
184    /// Clone groups belonging to this family, each wrapped with typed
185    /// `actions[]` so consumers that read `clone_families[].groups[]`
186    /// directly see the same shape as the top-level `clone_groups[]`.
187    pub groups: Vec<CloneGroupFinding>,
188    /// Total number of duplicated lines across all groups.
189    pub total_duplicated_lines: usize,
190    /// Total number of duplicated tokens across all groups.
191    pub total_duplicated_tokens: usize,
192    /// Refactoring suggestions for this family.
193    pub suggestions: Vec<RefactoringSuggestion>,
194    /// Suggested next steps: an `extract-shared` primary, one
195    /// `apply-suggestion` per [`RefactoringSuggestion`] on the family, and
196    /// a trailing `suppress-line`. Always emitted (possibly empty for
197    /// forward-compat).
198    pub actions: Vec<CloneFamilyAction>,
199}
200
201impl CloneFamilyFinding {
202    /// Build the wrapper from a raw [`CloneFamily`], computing the typed
203    /// `actions` array inline and wrapping each inner clone group with its
204    /// own typed actions.
205    #[must_use]
206    pub fn with_actions(family: CloneFamily) -> Self {
207        let actions = build_clone_family_actions(
208            &family.groups,
209            family.total_duplicated_lines,
210            &family.suggestions,
211        );
212        Self {
213            files: family.files,
214            groups: family
215                .groups
216                .into_iter()
217                .map(CloneGroupFinding::with_actions)
218                .collect(),
219            total_duplicated_lines: family.total_duplicated_lines,
220            total_duplicated_tokens: family.total_duplicated_tokens,
221            suggestions: family.suggestions,
222            actions,
223        }
224    }
225}
226
227fn build_clone_family_actions(
228    groups: &[CloneGroup],
229    total_duplicated_lines: usize,
230    suggestions: &[RefactoringSuggestion],
231) -> Vec<CloneFamilyAction> {
232    let group_count = groups.len();
233    let mut actions = Vec::with_capacity(2 + suggestions.len());
234    actions.push(CloneFamilyAction {
235        kind: CloneFamilyActionType::ExtractShared,
236        auto_fixable: false,
237        description: format!(
238            "Extract {group_count} duplicated code block{} ({total_duplicated_lines} lines) into a shared module",
239            if group_count == 1 { "" } else { "s" },
240        ),
241        note: Some(
242            "These clone groups share the same files, indicating a structural relationship; refactor together"
243                .to_string(),
244        ),
245        comment: None,
246    });
247    for suggestion in suggestions {
248        actions.push(CloneFamilyAction {
249            kind: CloneFamilyActionType::ApplySuggestion,
250            auto_fixable: false,
251            description: suggestion.description.clone(),
252            note: None,
253            comment: None,
254        });
255    }
256    actions.push(CloneFamilyAction {
257        kind: CloneFamilyActionType::SuppressLine,
258        auto_fixable: false,
259        description: SUPPRESS_DESCRIPTION.to_string(),
260        note: None,
261        comment: Some(SUPPRESS_COMMENT.to_string()),
262    });
263    actions
264}
265
266/// Wire-shape envelope for an [`AttributedCloneGroup`] finding (per-bucket
267/// duplication attribution emitted under `fallow dupes --group-by`).
268/// Flattens the attributed group and carries the same typed
269/// `CloneGroupAction` array as [`CloneGroupFinding`]; no `introduced`
270/// field because `fallow audit` does not run on grouped output.
271#[derive(Debug, Clone, Serialize)]
272#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
273pub struct AttributedCloneGroupFinding {
274    /// The underlying attributed clone group.
275    #[serde(flatten)]
276    pub group: AttributedCloneGroup,
277    /// Suggested next steps. Always emitted.
278    pub actions: Vec<CloneGroupAction>,
279}
280
281impl AttributedCloneGroupFinding {
282    /// Build the wrapper from an [`AttributedCloneGroup`], computing the
283    /// typed `actions` array inline from the attributed group's
284    /// `line_count` and instance count.
285    #[must_use]
286    pub fn with_actions(group: AttributedCloneGroup) -> Self {
287        let line_count = group.line_count;
288        let instance_count = group.instances.len();
289        let actions = vec![
290            CloneGroupAction {
291                kind: CloneGroupActionType::ExtractShared,
292                auto_fixable: false,
293                description: format!(
294                    "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
295                    if instance_count == 1 { "" } else { "s" },
296                ),
297                comment: None,
298            },
299            CloneGroupAction {
300                kind: CloneGroupActionType::SuppressLine,
301                auto_fixable: false,
302                description: SUPPRESS_DESCRIPTION.to_string(),
303                comment: Some(SUPPRESS_COMMENT.to_string()),
304            },
305        ];
306        Self { group, actions }
307    }
308}
309
310/// Wire-shape payload for `fallow dupes --format json` (the body that
311/// flattens into [`crate::output_envelope::DupesOutput`] and is also
312/// emitted under the `dupes` / `duplication` key inside the combined and
313/// audit envelopes).
314///
315/// Mirrors [`DuplicationReport`] field-for-field, except `clone_groups`
316/// and `clone_families` carry the typed wrapper envelopes instead of bare
317/// findings, so the schema (and any TS / agent consumer) sees the typed
318/// `actions[]` natively.
319#[derive(Debug, Clone, Serialize)]
320#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
321pub struct DupesReportPayload {
322    /// All detected clone groups, each wrapped with typed actions.
323    pub clone_groups: Vec<CloneGroupFinding>,
324    /// Clone families, each wrapped with typed actions. Inner `groups`
325    /// inside each [`CloneFamilyFinding`] are themselves wrapped as
326    /// [`CloneGroupFinding`] entries carrying their own `actions[]` (and
327    /// optional audit-mode `introduced` flag), so JSON-Schema strict
328    /// consumers and TS consumers reading `clone_families[].groups[]` see
329    /// the same shape as the top-level `clone_groups[]` array (preserves
330    /// the issue #393 regression contract).
331    pub clone_families: Vec<CloneFamilyFinding>,
332    /// Mirrored directory pairs.
333    #[serde(default, skip_serializing_if = "Vec::is_empty")]
334    pub mirrored_directories: Vec<MirroredDirectory>,
335    /// Aggregate duplication statistics.
336    pub stats: DuplicationStats,
337}
338
339impl DupesReportPayload {
340    /// Build the payload from a bare [`DuplicationReport`]. Wraps each
341    /// clone group and family with its typed actions; clones the
342    /// `mirrored_directories` and `stats` through unchanged.
343    #[must_use]
344    pub fn from_report(report: &DuplicationReport) -> Self {
345        Self {
346            clone_groups: report
347                .clone_groups
348                .iter()
349                .cloned()
350                .map(CloneGroupFinding::with_actions)
351                .collect(),
352            clone_families: report
353                .clone_families
354                .iter()
355                .cloned()
356                .map(CloneFamilyFinding::with_actions)
357                .collect(),
358            mirrored_directories: report.mirrored_directories.clone(),
359            stats: report.stats.clone(),
360        }
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use std::path::PathBuf;
367
368    use fallow_core::duplicates::{
369        CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
370    };
371
372    use super::*;
373
374    fn instance(path: &str) -> CloneInstance {
375        CloneInstance {
376            file: PathBuf::from(path),
377            start_line: 1,
378            end_line: 10,
379            start_col: 0,
380            end_col: 0,
381            fragment: String::new(),
382        }
383    }
384
385    fn group(instances: usize) -> CloneGroup {
386        CloneGroup {
387            instances: (0..instances)
388                .map(|i| instance(&format!("/root/file_{i}.ts")))
389                .collect(),
390            token_count: 100,
391            line_count: 20,
392        }
393    }
394
395    #[test]
396    fn clone_group_finding_position_0_is_extract_shared() {
397        let finding = CloneGroupFinding::with_actions(group(2));
398        assert_eq!(finding.actions.len(), 2);
399        assert_eq!(
400            finding.actions[0].kind,
401            CloneGroupActionType::ExtractShared,
402            "position 0 of a clone group must be `extract-shared` (jq scripts read .actions[0].type)",
403        );
404        assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
405        assert!(finding.introduced.is_none());
406    }
407
408    #[test]
409    fn clone_group_finding_description_pluralises_instance_count() {
410        let single = CloneGroupFinding::with_actions(group(1));
411        assert!(
412            single.actions[0].description.contains("1 instance"),
413            "single instance should be singular: {}",
414            single.actions[0].description
415        );
416        assert!(
417            !single.actions[0].description.contains("1 instances"),
418            "single instance must not pluralise: {}",
419            single.actions[0].description
420        );
421        let multi = CloneGroupFinding::with_actions(group(3));
422        assert!(
423            multi.actions[0].description.contains("3 instances"),
424            "multiple instances must pluralise: {}",
425            multi.actions[0].description
426        );
427    }
428
429    #[test]
430    fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
431        let family = CloneFamily {
432            files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
433            groups: vec![group(2), group(2)],
434            total_duplicated_lines: 40,
435            total_duplicated_tokens: 200,
436            suggestions: vec![
437                RefactoringSuggestion {
438                    kind: RefactoringKind::ExtractFunction,
439                    description: "Extract helper".to_string(),
440                    estimated_savings: 10,
441                },
442                RefactoringSuggestion {
443                    kind: RefactoringKind::ExtractModule,
444                    description: "Extract module".to_string(),
445                    estimated_savings: 30,
446                },
447            ],
448        };
449        let finding = CloneFamilyFinding::with_actions(family);
450        // 1 extract-shared + 2 apply-suggestion + 1 suppress-line = 4
451        assert_eq!(finding.actions.len(), 4);
452        assert_eq!(
453            finding.actions[0].kind,
454            CloneFamilyActionType::ExtractShared,
455            "position 0 of a clone family must be `extract-shared`",
456        );
457        assert_eq!(
458            finding.actions[1].kind,
459            CloneFamilyActionType::ApplySuggestion
460        );
461        assert_eq!(finding.actions[1].description, "Extract helper");
462        assert_eq!(
463            finding.actions[2].kind,
464            CloneFamilyActionType::ApplySuggestion
465        );
466        assert_eq!(finding.actions[2].description, "Extract module");
467        assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
468        // Issue #393 regression: every nested clone group inside a family
469        // must also carry its own typed actions array.
470        assert_eq!(finding.groups.len(), 2);
471        for inner in &finding.groups {
472            assert_eq!(inner.actions.len(), 2);
473            assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
474            assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
475        }
476    }
477
478    #[test]
479    fn clone_family_finding_with_no_suggestions_emits_two_actions() {
480        let family = CloneFamily {
481            files: vec![PathBuf::from("/root/a.ts")],
482            groups: vec![group(2)],
483            total_duplicated_lines: 20,
484            total_duplicated_tokens: 100,
485            suggestions: Vec::new(),
486        };
487        let finding = CloneFamilyFinding::with_actions(family);
488        assert_eq!(finding.actions.len(), 2);
489        assert_eq!(
490            finding.actions[0].kind,
491            CloneFamilyActionType::ExtractShared
492        );
493        assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
494    }
495
496    #[test]
497    fn payload_from_report_wraps_all_findings() {
498        let report = DuplicationReport {
499            clone_groups: vec![group(2), group(3)],
500            clone_families: vec![CloneFamily {
501                files: vec![PathBuf::from("/root/a.ts")],
502                groups: vec![group(2)],
503                total_duplicated_lines: 20,
504                total_duplicated_tokens: 100,
505                suggestions: Vec::new(),
506            }],
507            mirrored_directories: Vec::new(),
508            stats: DuplicationStats::default(),
509        };
510        let payload = DupesReportPayload::from_report(&report);
511        assert_eq!(payload.clone_groups.len(), 2);
512        assert_eq!(payload.clone_families.len(), 1);
513        // Sanity check: every group has the canonical 2-action array.
514        for finding in &payload.clone_groups {
515            assert_eq!(finding.actions.len(), 2);
516        }
517        // Sanity check: family with zero suggestions has 2 actions.
518        assert_eq!(payload.clone_families[0].actions.len(), 2);
519    }
520
521    #[test]
522    fn attributed_clone_group_finding_actions_match_clone_group_shape() {
523        use crate::report::dupes_grouping::AttributedInstance;
524        let attributed = AttributedCloneGroup {
525            primary_owner: "src".to_string(),
526            token_count: 100,
527            line_count: 20,
528            instances: vec![
529                AttributedInstance {
530                    instance: instance("/root/src/a.ts"),
531                    owner: "src".to_string(),
532                },
533                AttributedInstance {
534                    instance: instance("/root/src/b.ts"),
535                    owner: "src".to_string(),
536                },
537            ],
538        };
539        let finding = AttributedCloneGroupFinding::with_actions(attributed);
540        assert_eq!(finding.actions.len(), 2);
541        assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
542        assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
543    }
544}