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, CloneFingerprintSet, CloneGroup, DuplicationReport, DuplicationStats,
26    MirroredDirectory, 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    /// Stable content fingerprint, usually `dup:<8hex>` and widened on rare
126    /// report collisions. Addressable via `fallow dupes --trace dup:<fp>` (and
127    /// the `trace_clone` MCP tool) to deep-dive this group; shown alongside
128    /// each group in the human listing.
129    pub fingerprint: String,
130    /// Best-effort human-readable name for the clone: the dominant repeated
131    /// identifier across the duplicated fragment (e.g. a shared `parseCsv`
132    /// function). `None` when the clone has no clear dominant name (generic or
133    /// tied identifiers); consumers then fall back to a file-based label. Lets
134    /// editors and agents label a clone by what it is rather than an opaque
135    /// ordinal.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub suggested_name: Option<String>,
138    /// Suggested next steps: an `extract-shared` primary and a
139    /// `suppress-line` secondary. Always emitted (possibly empty for
140    /// forward-compat).
141    pub actions: Vec<CloneGroupAction>,
142    /// Set by the audit pass when this clone group is introduced relative
143    /// to the merge-base. `None` when serialized directly from Rust.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub introduced: Option<AuditIntroduced>,
146}
147
148impl CloneGroupFinding {
149    /// Build the wrapper from a raw [`CloneGroup`], computing the typed
150    /// `actions` array inline. `introduced` stays `None` and is set later
151    /// by `annotate_dupes_json` if the audit pass runs.
152    #[allow(
153        dead_code,
154        reason = "kept for focused wrapper tests and non-report construction paths"
155    )]
156    #[must_use]
157    pub fn with_actions(group: CloneGroup) -> Self {
158        let fingerprint = fallow_core::duplicates::clone_fingerprint(&group.instances);
159        Self::with_fingerprint(group, fingerprint)
160    }
161
162    /// Build the wrapper with a precomputed report-scoped fingerprint.
163    #[must_use]
164    pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
165        let suggested_name = fallow_core::duplicates::deepdive::dominant_identifier(&group);
166        let line_count = group.line_count;
167        let instance_count = group.instances.len();
168        let actions = vec![
169            CloneGroupAction {
170                kind: CloneGroupActionType::ExtractShared,
171                auto_fixable: false,
172                description: format!(
173                    "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
174                    if instance_count == 1 { "" } else { "s" },
175                ),
176                comment: None,
177            },
178            CloneGroupAction {
179                kind: CloneGroupActionType::SuppressLine,
180                auto_fixable: false,
181                description: SUPPRESS_DESCRIPTION.to_string(),
182                comment: Some(SUPPRESS_COMMENT.to_string()),
183            },
184        ];
185        Self {
186            fingerprint,
187            suggested_name,
188            group,
189            actions,
190            introduced: None,
191        }
192    }
193}
194
195/// Wire-shape envelope for a [`CloneFamily`] finding.
196///
197/// Unlike most `*Finding` wrappers this one is NOT `#[serde(flatten)]` over
198/// the bare [`CloneFamily`], because the family's nested
199/// `groups: Vec<CloneGroup>` field needs to carry the typed
200/// [`CloneGroupFinding`] wrapper too (so every nested clone group gets its
201/// own `actions[]` array, matching the legacy post-pass behavior; see issue
202/// #393 regression test). The wire shape stays byte-identical to the
203/// previous post-pass output. No `introduced` field because `fallow audit`
204/// attributes clone groups (not families) when running against a base ref.
205#[derive(Debug, Clone, Serialize)]
206#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
207pub struct CloneFamilyFinding {
208    /// The files involved in this family (sorted for stable output).
209    #[serde(serialize_with = "serde_path::serialize_vec")]
210    pub files: Vec<PathBuf>,
211    /// Clone groups belonging to this family, each wrapped with typed
212    /// `actions[]` so consumers that read `clone_families[].groups[]`
213    /// directly see the same shape as the top-level `clone_groups[]`.
214    pub groups: Vec<CloneGroupFinding>,
215    /// Total number of duplicated lines across all groups.
216    pub total_duplicated_lines: usize,
217    /// Total number of duplicated tokens across all groups.
218    pub total_duplicated_tokens: usize,
219    /// Refactoring suggestions for this family.
220    pub suggestions: Vec<RefactoringSuggestion>,
221    /// Suggested next steps: an `extract-shared` primary, one
222    /// `apply-suggestion` per [`RefactoringSuggestion`] on the family, and
223    /// a trailing `suppress-line`. Always emitted (possibly empty for
224    /// forward-compat).
225    pub actions: Vec<CloneFamilyAction>,
226}
227
228impl CloneFamilyFinding {
229    /// Build the wrapper from a raw [`CloneFamily`], computing the typed
230    /// `actions` array inline and wrapping each inner clone group with its
231    /// own typed actions.
232    #[allow(
233        dead_code,
234        reason = "kept for focused wrapper tests and non-report construction paths"
235    )]
236    #[must_use]
237    pub fn with_actions(family: CloneFamily) -> Self {
238        let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
239        Self::with_fingerprints(family, &fingerprints)
240    }
241
242    /// Build the wrapper using the report-scoped fingerprint assignment shared
243    /// by all duplication output surfaces.
244    #[must_use]
245    pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
246        let actions = build_clone_family_actions(
247            &family.groups,
248            family.total_duplicated_lines,
249            &family.suggestions,
250        );
251        Self {
252            files: family.files,
253            groups: family
254                .groups
255                .into_iter()
256                .map(|group| {
257                    let fingerprint = fingerprints.fingerprint_for_group(&group);
258                    CloneGroupFinding::with_fingerprint(group, fingerprint)
259                })
260                .collect(),
261            total_duplicated_lines: family.total_duplicated_lines,
262            total_duplicated_tokens: family.total_duplicated_tokens,
263            suggestions: family.suggestions,
264            actions,
265        }
266    }
267}
268
269fn build_clone_family_actions(
270    groups: &[CloneGroup],
271    total_duplicated_lines: usize,
272    suggestions: &[RefactoringSuggestion],
273) -> Vec<CloneFamilyAction> {
274    let group_count = groups.len();
275    let mut actions = Vec::with_capacity(2 + suggestions.len());
276    actions.push(CloneFamilyAction {
277        kind: CloneFamilyActionType::ExtractShared,
278        auto_fixable: false,
279        description: format!(
280            "Extract {group_count} duplicated code block{} ({total_duplicated_lines} lines) into a shared module",
281            if group_count == 1 { "" } else { "s" },
282        ),
283        note: Some(
284            "These clone groups share the same files, indicating a structural relationship; refactor together"
285                .to_string(),
286        ),
287        comment: None,
288    });
289    for suggestion in suggestions {
290        actions.push(CloneFamilyAction {
291            kind: CloneFamilyActionType::ApplySuggestion,
292            auto_fixable: false,
293            description: suggestion.description.clone(),
294            note: None,
295            comment: None,
296        });
297    }
298    actions.push(CloneFamilyAction {
299        kind: CloneFamilyActionType::SuppressLine,
300        auto_fixable: false,
301        description: SUPPRESS_DESCRIPTION.to_string(),
302        note: None,
303        comment: Some(SUPPRESS_COMMENT.to_string()),
304    });
305    actions
306}
307
308/// Wire-shape envelope for an [`AttributedCloneGroup`] finding (per-bucket
309/// duplication attribution emitted under `fallow dupes --group-by`).
310/// Flattens the attributed group and carries the same typed
311/// `CloneGroupAction` array as [`CloneGroupFinding`]; no `introduced`
312/// field because `fallow audit` does not run on grouped output.
313#[derive(Debug, Clone, Serialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315pub struct AttributedCloneGroupFinding {
316    /// The underlying attributed clone group.
317    #[serde(flatten)]
318    pub group: AttributedCloneGroup,
319    /// Stable content fingerprint, usually `dup:<8hex>` and widened on rare
320    /// report collisions. Addressable via `fallow dupes --trace dup:<fp>`.
321    /// Computed from the group's instances, so it matches the top-level
322    /// `clone_groups[].fingerprint` for the same clone.
323    pub fingerprint: String,
324    /// Suggested next steps. Always emitted.
325    pub actions: Vec<CloneGroupAction>,
326}
327
328impl AttributedCloneGroupFinding {
329    /// Build the wrapper from an [`AttributedCloneGroup`], computing the
330    /// typed `actions` array inline from the attributed group's
331    /// `line_count` and instance count.
332    #[allow(
333        dead_code,
334        reason = "kept for focused wrapper tests and non-report construction paths"
335    )]
336    #[must_use]
337    pub fn with_actions(group: AttributedCloneGroup) -> Self {
338        let fingerprint = group.instances.first().map_or_else(
339            || fallow_core::duplicates::fingerprint_for_fragment(""),
340            |ai| fallow_core::duplicates::fingerprint_for_fragment(&ai.instance.fragment),
341        );
342        Self::with_fingerprint(group, fingerprint)
343    }
344
345    /// Build the wrapper with a precomputed report-scoped fingerprint.
346    #[must_use]
347    pub fn with_fingerprint(group: AttributedCloneGroup, fingerprint: String) -> Self {
348        let line_count = group.line_count;
349        let instance_count = group.instances.len();
350        let actions = vec![
351            CloneGroupAction {
352                kind: CloneGroupActionType::ExtractShared,
353                auto_fixable: false,
354                description: format!(
355                    "Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
356                    if instance_count == 1 { "" } else { "s" },
357                ),
358                comment: None,
359            },
360            CloneGroupAction {
361                kind: CloneGroupActionType::SuppressLine,
362                auto_fixable: false,
363                description: SUPPRESS_DESCRIPTION.to_string(),
364                comment: Some(SUPPRESS_COMMENT.to_string()),
365            },
366        ];
367        Self {
368            group,
369            fingerprint,
370            actions,
371        }
372    }
373}
374
375/// Wire-shape payload for `fallow dupes --format json` (the body that
376/// flattens into [`crate::output_envelope::DupesOutput`] and is also
377/// emitted under the `dupes` / `duplication` key inside the combined and
378/// audit envelopes).
379///
380/// Mirrors [`DuplicationReport`] field-for-field, except `clone_groups`
381/// and `clone_families` carry the typed wrapper envelopes instead of bare
382/// findings, so the schema (and any TS / agent consumer) sees the typed
383/// `actions[]` natively.
384#[derive(Debug, Clone, Serialize)]
385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
386pub struct DupesReportPayload {
387    /// All detected clone groups, each wrapped with typed actions.
388    pub clone_groups: Vec<CloneGroupFinding>,
389    /// Clone families, each wrapped with typed actions. Inner `groups`
390    /// inside each [`CloneFamilyFinding`] are themselves wrapped as
391    /// [`CloneGroupFinding`] entries carrying their own `actions[]` (and
392    /// optional audit-mode `introduced` flag), so JSON-Schema strict
393    /// consumers and TS consumers reading `clone_families[].groups[]` see
394    /// the same shape as the top-level `clone_groups[]` array (preserves
395    /// the issue #393 regression contract).
396    pub clone_families: Vec<CloneFamilyFinding>,
397    /// Mirrored directory pairs.
398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
399    pub mirrored_directories: Vec<MirroredDirectory>,
400    /// Aggregate duplication statistics.
401    pub stats: DuplicationStats,
402}
403
404impl DupesReportPayload {
405    /// Build the payload from a bare [`DuplicationReport`]. Wraps each
406    /// clone group and family with its typed actions; clones the
407    /// `mirrored_directories` and `stats` through unchanged.
408    #[must_use]
409    pub fn from_report(report: &DuplicationReport) -> Self {
410        let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
411        Self {
412            clone_groups: report
413                .clone_groups
414                .iter()
415                .map(|group| {
416                    CloneGroupFinding::with_fingerprint(
417                        group.clone(),
418                        fingerprints.fingerprint_for_group(group),
419                    )
420                })
421                .collect(),
422            clone_families: report
423                .clone_families
424                .iter()
425                .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
426                .collect(),
427            mirrored_directories: report.mirrored_directories.clone(),
428            stats: report.stats.clone(),
429        }
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use std::path::PathBuf;
436
437    use fallow_core::duplicates::{
438        CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
439    };
440
441    use super::*;
442
443    fn instance(path: &str) -> CloneInstance {
444        CloneInstance {
445            file: PathBuf::from(path),
446            start_line: 1,
447            end_line: 10,
448            start_col: 0,
449            end_col: 0,
450            fragment: String::new(),
451        }
452    }
453
454    fn group(instances: usize) -> CloneGroup {
455        CloneGroup {
456            instances: (0..instances)
457                .map(|i| instance(&format!("/root/file_{i}.ts")))
458                .collect(),
459            token_count: 100,
460            line_count: 20,
461        }
462    }
463
464    #[test]
465    fn clone_group_finding_position_0_is_extract_shared() {
466        let finding = CloneGroupFinding::with_actions(group(2));
467        assert_eq!(finding.actions.len(), 2);
468        assert_eq!(
469            finding.actions[0].kind,
470            CloneGroupActionType::ExtractShared,
471            "position 0 of a clone group must be `extract-shared` (jq scripts read .actions[0].type)",
472        );
473        assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
474        assert!(finding.introduced.is_none());
475    }
476
477    #[test]
478    fn clone_group_finding_surfaces_dominant_identifier() {
479        let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
480        let g = CloneGroup {
481            instances: vec![
482                CloneInstance {
483                    file: PathBuf::from("/root/a.ts"),
484                    start_line: 1,
485                    end_line: 3,
486                    start_col: 0,
487                    end_col: 0,
488                    fragment: fragment.to_string(),
489                },
490                CloneInstance {
491                    file: PathBuf::from("/root/b.ts"),
492                    start_line: 1,
493                    end_line: 3,
494                    start_col: 0,
495                    end_col: 0,
496                    fragment: fragment.to_string(),
497                },
498            ],
499            token_count: 100,
500            line_count: 3,
501        };
502        let finding = CloneGroupFinding::with_actions(g);
503        assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
504    }
505
506    #[test]
507    fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
508        // The shared `instance` helper uses an empty fragment, so there is no
509        // dominant identifier and the label falls back to a file-based name.
510        let finding = CloneGroupFinding::with_actions(group(2));
511        assert!(finding.suggested_name.is_none());
512    }
513
514    #[test]
515    fn clone_group_finding_description_pluralises_instance_count() {
516        let single = CloneGroupFinding::with_actions(group(1));
517        assert!(
518            single.actions[0].description.contains("1 instance"),
519            "single instance should be singular: {}",
520            single.actions[0].description
521        );
522        assert!(
523            !single.actions[0].description.contains("1 instances"),
524            "single instance must not pluralise: {}",
525            single.actions[0].description
526        );
527        let multi = CloneGroupFinding::with_actions(group(3));
528        assert!(
529            multi.actions[0].description.contains("3 instances"),
530            "multiple instances must pluralise: {}",
531            multi.actions[0].description
532        );
533    }
534
535    #[test]
536    fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
537        let family = CloneFamily {
538            files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
539            groups: vec![group(2), group(2)],
540            total_duplicated_lines: 40,
541            total_duplicated_tokens: 200,
542            suggestions: vec![
543                RefactoringSuggestion {
544                    kind: RefactoringKind::ExtractFunction,
545                    description: "Extract helper".to_string(),
546                    estimated_savings: 10,
547                },
548                RefactoringSuggestion {
549                    kind: RefactoringKind::ExtractModule,
550                    description: "Extract module".to_string(),
551                    estimated_savings: 30,
552                },
553            ],
554        };
555        let finding = CloneFamilyFinding::with_actions(family);
556        assert_eq!(finding.actions.len(), 4);
557        assert_eq!(
558            finding.actions[0].kind,
559            CloneFamilyActionType::ExtractShared,
560            "position 0 of a clone family must be `extract-shared`",
561        );
562        assert_eq!(
563            finding.actions[1].kind,
564            CloneFamilyActionType::ApplySuggestion
565        );
566        assert_eq!(finding.actions[1].description, "Extract helper");
567        assert_eq!(
568            finding.actions[2].kind,
569            CloneFamilyActionType::ApplySuggestion
570        );
571        assert_eq!(finding.actions[2].description, "Extract module");
572        assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
573        assert_eq!(finding.groups.len(), 2);
574        for inner in &finding.groups {
575            assert_eq!(inner.actions.len(), 2);
576            assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
577            assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
578        }
579    }
580
581    #[test]
582    fn clone_family_finding_with_no_suggestions_emits_two_actions() {
583        let family = CloneFamily {
584            files: vec![PathBuf::from("/root/a.ts")],
585            groups: vec![group(2)],
586            total_duplicated_lines: 20,
587            total_duplicated_tokens: 100,
588            suggestions: Vec::new(),
589        };
590        let finding = CloneFamilyFinding::with_actions(family);
591        assert_eq!(finding.actions.len(), 2);
592        assert_eq!(
593            finding.actions[0].kind,
594            CloneFamilyActionType::ExtractShared
595        );
596        assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
597    }
598
599    #[test]
600    fn payload_from_report_wraps_all_findings() {
601        let report = DuplicationReport {
602            clone_groups: vec![group(2), group(3)],
603            clone_families: vec![CloneFamily {
604                files: vec![PathBuf::from("/root/a.ts")],
605                groups: vec![group(2)],
606                total_duplicated_lines: 20,
607                total_duplicated_tokens: 100,
608                suggestions: Vec::new(),
609            }],
610            mirrored_directories: Vec::new(),
611            stats: DuplicationStats::default(),
612        };
613        let payload = DupesReportPayload::from_report(&report);
614        assert_eq!(payload.clone_groups.len(), 2);
615        assert_eq!(payload.clone_families.len(), 1);
616        for finding in &payload.clone_groups {
617            assert_eq!(finding.actions.len(), 2);
618        }
619        assert_eq!(payload.clone_families[0].actions.len(), 2);
620    }
621
622    #[test]
623    fn attributed_clone_group_finding_actions_match_clone_group_shape() {
624        use crate::report::dupes_grouping::AttributedInstance;
625        let attributed = AttributedCloneGroup {
626            primary_owner: "src".to_string(),
627            token_count: 100,
628            line_count: 20,
629            instances: vec![
630                AttributedInstance {
631                    instance: instance("/root/src/a.ts"),
632                    owner: "src".to_string(),
633                },
634                AttributedInstance {
635                    instance: instance("/root/src/b.ts"),
636                    owner: "src".to_string(),
637                },
638            ],
639        };
640        let finding = AttributedCloneGroupFinding::with_actions(attributed);
641        assert_eq!(finding.actions.len(), 2);
642        assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
643        assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
644    }
645}