Skip to main content

fallow_api/
dupes_output.rs

1//! Shared duplication JSON payload contracts for programmatic consumers.
2
3use std::path::{Path, PathBuf};
4
5use fallow_engine::duplicates::{
6    CloneFingerprintSet, clone_fingerprint, dominant_identifier, fingerprint_for_fragment,
7};
8use fallow_output::{
9    CloneFamilyAction, CloneGroupAction, CodeClimateIssue, CodeClimateIssueInput,
10    CodeClimateSeverity, clone_family_actions, clone_group_actions, codeclimate_fingerprint_hash,
11    normalize_uri,
12};
13use fallow_types::duplicates::{
14    CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, MirroredDirectory,
15    RefactoringSuggestion, clone_location_spread,
16};
17use fallow_types::envelope::AuditIntroduced;
18use fallow_types::serde_path;
19use serde::Serialize;
20
21/// A clone instance plus its per-instance owner key (for inline JSON / SARIF
22/// rendering).
23///
24/// Each instance carries its own `owner` field alongside the standard
25/// `CloneInstance` shape (file / start_line / end_line / start_col / end_col /
26/// fragment), so consumers can attribute instances to resolver keys without
27/// re-resolving paths.
28#[derive(Debug, Clone, Serialize)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30pub struct AttributedInstance {
31    /// The original clone instance.
32    #[serde(flatten)]
33    pub instance: CloneInstance,
34    /// Resolver key for this specific instance (per-instance, not the
35    /// group-level largest-owner).
36    pub owner: String,
37}
38
39/// A clone group annotated with largest-owner attribution and per-instance
40/// owner keys.
41#[derive(Debug, Clone, Serialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43pub struct AttributedCloneGroup {
44    /// Largest-owner attribution: the resolver key with the most instances in
45    /// this clone group. Ties broken alphabetically (smallest key wins).
46    pub primary_owner: String,
47    /// Number of tokens in the clone group.
48    pub token_count: usize,
49    /// Number of source lines in the clone group.
50    pub line_count: usize,
51    /// Lowest all-pairs similarity for a near-miss clone group.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    #[cfg_attr(feature = "schema", schemars(with = "f64"))]
54    pub similarity: Option<f64>,
55    /// Each instance carries its own `owner` field alongside the standard
56    /// CloneInstance shape.
57    pub instances: Vec<AttributedInstance>,
58}
59
60impl AttributedCloneGroup {
61    /// Return the report-scoped fingerprint for this attributed group.
62    #[must_use]
63    pub fn fingerprint(&self, fingerprints: &CloneFingerprintSet) -> String {
64        let instances: Vec<_> = self
65            .instances
66            .iter()
67            .map(|instance| instance.instance.clone())
68            .collect();
69        fingerprints.fingerprint_for_parts(&instances, self.token_count, self.line_count)
70    }
71}
72
73/// Wire-shape envelope for an [`AttributedCloneGroup`] finding (per-bucket
74/// duplication attribution emitted under `fallow dupes --group-by`).
75/// Flattens the attributed group and carries the same typed
76/// `CloneGroupAction` array as `CloneGroupFinding`; no `introduced`
77/// field because `fallow audit` does not run on grouped output.
78#[derive(Debug, Clone, Serialize)]
79#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
80pub struct AttributedCloneGroupFinding {
81    /// The underlying attributed clone group.
82    #[serde(flatten)]
83    pub group: AttributedCloneGroup,
84    /// Stable content fingerprint, usually `dup:<8hex>` and widened on rare
85    /// report collisions. Addressable via `fallow dupes --trace dup:<fp>`.
86    /// Computed from the group's instances, so it matches the top-level
87    /// `clone_groups[].fingerprint` for the same clone.
88    pub fingerprint: String,
89    /// Maximum directory-tree or same-file line distance between instances.
90    pub spread: usize,
91    /// Suggested next steps. Always emitted.
92    pub actions: Vec<CloneGroupAction>,
93}
94
95impl AttributedCloneGroupFinding {
96    /// Build the wrapper from an [`AttributedCloneGroup`].
97    #[allow(
98        dead_code,
99        reason = "kept for focused wrapper tests and non-report construction paths"
100    )]
101    #[must_use]
102    pub fn with_actions(group: AttributedCloneGroup) -> Self {
103        let fingerprint = group.instances.first().map_or_else(
104            || fingerprint_for_fragment(""),
105            |ai| fingerprint_for_fragment(&ai.instance.fragment),
106        );
107        Self::with_fingerprint(group, fingerprint)
108    }
109
110    /// Build the wrapper with a precomputed report-scoped fingerprint.
111    #[must_use]
112    pub fn with_fingerprint(group: AttributedCloneGroup, fingerprint: String) -> Self {
113        let spread = clone_location_spread(group.instances.iter().map(|instance| {
114            (
115                instance.instance.file.as_path(),
116                instance.instance.start_line,
117                instance.instance.end_line,
118            )
119        }));
120        let actions = clone_group_actions(group.line_count, group.instances.len());
121        Self {
122            group,
123            fingerprint,
124            spread,
125            actions,
126        }
127    }
128}
129
130/// A single grouped duplication bucket. Per-group `stats` are dedup-aware and
131/// computed over the FULL group BEFORE any `--top` truncation.
132#[derive(Debug, Clone, Serialize)]
133#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
134pub struct DuplicationGroup {
135    /// Group label (owner / directory / package / section). `(unowned)` for
136    /// files with no CODEOWNERS rule, `(no section)` for pre-section rules in
137    /// section mode.
138    pub key: String,
139    /// Dedup-aware aggregate stats for the group.
140    pub stats: DuplicationStats,
141    /// Clone groups attributed to this owner, each wrapped with the typed
142    /// `actions[]` array. Each group's `primary_owner` is its largest-owner
143    /// key; per-instance `owner` lets consumers see cross-bucket fan-out
144    /// without re-resolving paths.
145    pub clone_groups: Vec<AttributedCloneGroupFinding>,
146    /// Clone families overlapping this bucket, each wrapped with the typed
147    /// `actions[]` array.
148    pub clone_families: Vec<CloneFamilyFinding>,
149}
150
151/// Wrapper carrying the resolver mode label and grouped buckets.
152#[derive(Debug, Clone, Serialize)]
153pub struct DuplicationGrouping {
154    /// Resolver mode label (`"owner"`, `"directory"`, `"package"`, `"section"`).
155    pub mode: &'static str,
156    /// One bucket per resolver key.
157    pub groups: Vec<DuplicationGroup>,
158}
159
160/// Why the audit new-only gate demoted an introduced clone group to
161/// inherited. Serializes as a kebab-case string on the wire (for example
162/// `"no-added-lines"`).
163///
164/// Further variants may be added in later releases; consumers should treat an
165/// unknown value as "some demotion reason" rather than failing.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
167#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
168#[serde(rename_all = "kebab-case")]
169pub enum CloneDemotionReason {
170    /// No instance of the group overlaps an added line in the run's diff: the
171    /// group was re-shaped by removals elsewhere in the changeset, not written
172    /// by it (issue #2164).
173    NoAddedLines,
174}
175
176impl CloneDemotionReason {
177    /// The kebab-case wire literal, obtained from the serde representation so
178    /// human output and JSON cannot diverge.
179    #[must_use]
180    pub fn wire_name(self) -> String {
181        match serde_json::to_value(self) {
182            Ok(serde_json::Value::String(name)) => name,
183            _ => String::new(),
184        }
185    }
186}
187
188/// Wire-shape envelope for a [`CloneGroup`] finding. Flattens the bare
189/// group via `#[serde(flatten)]` and carries a typed `actions` array plus
190/// the optional audit-mode `introduced` flag. The typed envelope replaced
191/// the legacy JSON post-pass injection; a guard test in
192/// `crates/cli/src/report/json.rs` rejects any reintroduced post-pass.
193#[derive(Debug, Clone, Serialize)]
194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
195pub struct CloneGroupFinding {
196    /// The underlying clone group.
197    #[serde(flatten)]
198    pub group: CloneGroup,
199    /// Stable content fingerprint, usually `dup:<8hex>` and widened on rare
200    /// report collisions. Addressable via `fallow dupes --trace dup:<fp>` (and
201    /// the `trace_clone` MCP tool) to deep-dive this group; shown alongside
202    /// each group in the human listing.
203    pub fingerprint: String,
204    /// Maximum directory-tree or same-file line distance between instances.
205    pub spread: usize,
206    /// Best-effort human-readable name for the clone: the dominant repeated
207    /// identifier across the duplicated fragment (e.g. a shared `parseCsv`
208    /// function). `None` when the clone has no clear dominant name (generic or
209    /// tied identifiers); consumers then fall back to a file-based label. Lets
210    /// editors and agents label a clone by what it is rather than an opaque
211    /// ordinal.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub suggested_name: Option<String>,
214    /// Suggested next steps: an `extract-shared` primary and a
215    /// `suppress-line` secondary. Always emitted (possibly empty for
216    /// forward-compat).
217    pub actions: Vec<CloneGroupAction>,
218    /// Set by the audit pass when this clone group is introduced relative
219    /// to the merge-base. `None` when serialized directly from Rust.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub introduced: Option<AuditIntroduced>,
222    /// Set only by `fallow audit` under `--gate new-only`, on groups whose
223    /// `introduced` flag the gate demoted to `false`: why the demotion
224    /// happened. `None` everywhere else, including `fallow dupes
225    /// --format json` (issue #2220).
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub demotion_reason: Option<CloneDemotionReason>,
228}
229
230impl CloneGroupFinding {
231    /// Build the wrapper from a raw [`CloneGroup`].
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(group: CloneGroup) -> Self {
238        let fingerprint = clone_fingerprint(&group.instances);
239        Self::with_fingerprint(group, fingerprint)
240    }
241
242    /// Build the wrapper with a precomputed report-scoped fingerprint.
243    #[must_use]
244    pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
245        let spread = group.spread();
246        let suggested_name = dominant_identifier(&group);
247        let actions = clone_group_actions(group.line_count, group.instances.len());
248        Self {
249            fingerprint,
250            spread,
251            suggested_name,
252            group,
253            actions,
254            introduced: None,
255            demotion_reason: None,
256        }
257    }
258}
259
260/// Wire-shape envelope for a [`CloneFamily`] finding.
261///
262/// Unlike most `*Finding` wrappers this one is NOT `#[serde(flatten)]` over
263/// the bare [`CloneFamily`], because the family's nested
264/// `groups: Vec<CloneGroup>` field needs to carry the typed
265/// `CloneGroupFinding` wrapper too (so every nested clone group gets its
266/// own `actions[]` array, matching the legacy post-pass behavior; see issue
267/// #393 regression test). The wire shape stays byte-identical to the
268/// previous post-pass output. No `introduced` field because `fallow audit`
269/// attributes clone groups (not families) when running against a base ref.
270#[derive(Debug, Clone, Serialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272pub struct CloneFamilyFinding {
273    /// The files involved in this family.
274    #[serde(serialize_with = "serde_path::serialize_vec")]
275    pub files: Vec<PathBuf>,
276    /// Clone groups belonging to this family, each wrapped with typed
277    /// `actions[]` so consumers that read `clone_families[].groups[]`
278    /// directly see the same shape as the top-level `clone_groups[]`.
279    pub groups: Vec<CloneGroupFinding>,
280    /// Total number of duplicated lines across all groups.
281    pub total_duplicated_lines: usize,
282    /// Total number of duplicated tokens across all groups.
283    pub total_duplicated_tokens: usize,
284    /// Refactoring suggestions for this family.
285    pub suggestions: Vec<RefactoringSuggestion>,
286    /// Suggested next steps: an `extract-shared` primary, one
287    /// `apply-suggestion` per `RefactoringSuggestion` on the family, and
288    /// a trailing `suppress-line`. Always emitted (possibly empty for
289    /// forward-compat).
290    pub actions: Vec<CloneFamilyAction>,
291}
292
293impl CloneFamilyFinding {
294    /// Build the wrapper from a raw [`CloneFamily`].
295    #[allow(
296        dead_code,
297        reason = "kept for focused wrapper tests and non-report construction paths"
298    )]
299    #[must_use]
300    pub fn with_actions(family: CloneFamily) -> Self {
301        let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
302        Self::with_fingerprints(family, &fingerprints)
303    }
304
305    /// Build the wrapper using the report-scoped fingerprint assignment shared
306    /// by all duplication output surfaces.
307    #[must_use]
308    pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
309        let actions = build_clone_family_actions(
310            &family.groups,
311            family.total_duplicated_lines,
312            &family.suggestions,
313        );
314        Self {
315            files: family.files,
316            groups: family
317                .groups
318                .into_iter()
319                .map(|group| {
320                    let fingerprint = fingerprints.fingerprint_for_group(&group);
321                    CloneGroupFinding::with_fingerprint(group, fingerprint)
322                })
323                .collect(),
324            total_duplicated_lines: family.total_duplicated_lines,
325            total_duplicated_tokens: family.total_duplicated_tokens,
326            suggestions: family.suggestions,
327            actions,
328        }
329    }
330}
331
332fn build_clone_family_actions(
333    groups: &[CloneGroup],
334    total_duplicated_lines: usize,
335    suggestions: &[RefactoringSuggestion],
336) -> Vec<CloneFamilyAction> {
337    clone_family_actions(
338        groups.len(),
339        total_duplicated_lines,
340        suggestions
341            .iter()
342            .map(|suggestion| suggestion.description.as_str()),
343    )
344}
345
346/// Wire-shape payload for `fallow dupes --format json` (the body that
347/// flattens into the `DupesOutput` envelope and is also
348/// emitted under the `dupes` / `duplication` key inside the combined and
349/// audit envelopes).
350///
351/// Mirrors [`DuplicationReport`] field-for-field, except `clone_groups`
352/// and `clone_families` carry the typed wrapper envelopes instead of bare
353/// findings, so the schema (and any TS / agent consumer) sees the typed
354/// `actions[]` natively.
355#[derive(Debug, Clone, Serialize)]
356#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
357pub struct DupesReportPayload {
358    /// All detected clone groups, each wrapped with typed actions.
359    pub clone_groups: Vec<CloneGroupFinding>,
360    /// Clone families, each wrapped with typed actions. Inner `groups`
361    /// inside each `CloneFamilyFinding` are themselves wrapped as
362    /// `CloneGroupFinding` entries carrying their own `actions[]` (and
363    /// optional audit-mode `introduced` flag), so JSON-Schema strict
364    /// consumers and TS consumers reading `clone_families[].groups[]` see
365    /// the same shape as the top-level `clone_groups[]` array (preserves
366    /// the issue #393 regression contract).
367    pub clone_families: Vec<CloneFamilyFinding>,
368    /// Mirrored directory pairs.
369    #[serde(default, skip_serializing_if = "Vec::is_empty")]
370    pub mirrored_directories: Vec<MirroredDirectory>,
371    /// Aggregate duplication statistics.
372    pub stats: DuplicationStats,
373}
374
375impl DupesReportPayload {
376    /// Build the payload from a bare [`DuplicationReport`].
377    #[must_use]
378    pub fn from_report(report: &DuplicationReport) -> Self {
379        let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
380        Self {
381            clone_groups: report
382                .clone_groups
383                .iter()
384                .map(|group| {
385                    CloneGroupFinding::with_fingerprint(
386                        group.clone(),
387                        fingerprints.fingerprint_for_group(group),
388                    )
389                })
390                .collect(),
391            clone_families: report
392                .clone_families
393                .iter()
394                .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
395                .collect(),
396            mirrored_directories: report.mirrored_directories.clone(),
397            stats: report.stats.clone(),
398        }
399    }
400}
401
402/// Build CodeClimate issues from duplication analysis results.
403///
404/// `fallow-output` owns the CodeClimate wire DTOs. This API layer combines
405/// those DTOs with the engine-owned duplication report so CLI and future
406/// embedders can share the same issue construction policy.
407#[must_use]
408#[expect(
409    clippy::cast_possible_truncation,
410    reason = "line numbers are bounded by source size"
411)]
412pub fn build_duplication_codeclimate(
413    report: &DuplicationReport,
414    root: &Path,
415) -> Vec<CodeClimateIssue> {
416    let mut issues = Vec::new();
417    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
418
419    for group in &report.clone_groups {
420        let clone_fingerprint = fingerprints.fingerprint_for_group(group);
421        let token_str = group.token_count.to_string();
422        let line_count_str = group.line_count.to_string();
423        let fragment_prefix: String = group
424            .instances
425            .first()
426            .map(|inst| inst.fragment.chars().take(64).collect())
427            .unwrap_or_default();
428
429        for (instance_index, instance) in group.instances.iter().enumerate() {
430            let path = codeclimate_path(&instance.file, root);
431            let start_str = instance.start_line.to_string();
432            let fp = codeclimate_fingerprint_hash(&[
433                "fallow/code-duplication",
434                &path,
435                &start_str,
436                &token_str,
437                &line_count_str,
438                &fragment_prefix,
439            ]);
440            let mut issue = fallow_output::build_codeclimate_issue(CodeClimateIssueInput {
441                check_name: "fallow/code-duplication",
442                description: &format!(
443                    "Code clone {clone_fingerprint} ({} lines, {} instances)",
444                    group.line_count,
445                    group.instances.len()
446                ),
447                severity: CodeClimateSeverity::Minor,
448                category: "Duplication",
449                path: &path,
450                begin_line: Some(instance.start_line as u32),
451                fingerprint: &fp,
452            });
453            issue.location.lines.end = Some(instance.end_line as u32);
454            issue.other_locations = group
455                .instances
456                .iter()
457                .enumerate()
458                .filter(|(peer_index, _)| *peer_index != instance_index)
459                .map(|(_, peer)| fallow_output::CodeClimateLocation {
460                    path: codeclimate_path(&peer.file, root),
461                    lines: fallow_output::CodeClimateLines {
462                        begin: peer.start_line as u32,
463                        end: Some(peer.end_line as u32),
464                    },
465                })
466                .collect();
467            issue.other_locations.sort_by(|a, b| {
468                (&a.path, a.lines.begin, a.lines.end).cmp(&(&b.path, b.lines.begin, b.lines.end))
469            });
470            issues.push(issue);
471        }
472    }
473
474    issues
475}
476
477fn codeclimate_path(path: &Path, root: &Path) -> String {
478    normalize_uri(
479        &path
480            .strip_prefix(root)
481            .unwrap_or(path)
482            .display()
483            .to_string(),
484    )
485}
486
487#[cfg(test)]
488mod tests {
489    use std::path::Path;
490
491    use fallow_output::{CloneFamilyActionType, CloneGroupActionType};
492    use fallow_types::duplicates::{
493        CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
494    };
495
496    use super::*;
497
498    fn instance(path: &str) -> CloneInstance {
499        CloneInstance {
500            file: PathBuf::from(path),
501            start_line: 1,
502            end_line: 10,
503            start_col: 0,
504            end_col: 0,
505            fragment: String::new(),
506        }
507    }
508
509    fn group(instances: usize) -> CloneGroup {
510        CloneGroup {
511            instances: (0..instances)
512                .map(|i| instance(&format!("/root/file_{i}.ts")))
513                .collect(),
514            token_count: 100,
515            line_count: 20,
516            similarity: None,
517        }
518    }
519
520    #[test]
521    fn clone_group_finding_position_0_is_extract_shared() {
522        let finding = CloneGroupFinding::with_actions(group(2));
523        assert_eq!(finding.actions.len(), 2);
524        assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
525        assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
526        assert!(finding.introduced.is_none());
527        assert!(finding.demotion_reason.is_none());
528    }
529
530    #[test]
531    fn clone_group_finding_omits_audit_only_fields_outside_audit() {
532        // `fallow dupes --format json` serializes findings straight from Rust;
533        // the audit-only `introduced` / `demotion_reason` keys must not appear.
534        let finding = CloneGroupFinding::with_actions(group(2));
535        let value = serde_json::to_value(&finding).expect("finding serializes");
536        assert!(value.get("introduced").is_none());
537        assert!(value.get("demotion_reason").is_none());
538    }
539
540    #[test]
541    fn clone_demotion_reason_wire_name_matches_serde_representation() {
542        let reason = CloneDemotionReason::NoAddedLines;
543        assert_eq!(
544            serde_json::to_value(reason).expect("reason serializes"),
545            serde_json::Value::String(reason.wire_name())
546        );
547        assert_eq!(reason.wire_name(), "no-added-lines");
548    }
549
550    #[test]
551    fn attributed_clone_group_finding_actions_match_clone_group_shape() {
552        let attributed = AttributedCloneGroup {
553            primary_owner: "src".to_string(),
554            token_count: 100,
555            line_count: 20,
556            similarity: None,
557            instances: vec![
558                AttributedInstance {
559                    instance: instance("/root/src/a.ts"),
560                    owner: "src".to_string(),
561                },
562                AttributedInstance {
563                    instance: instance("/root/src/b.ts"),
564                    owner: "src".to_string(),
565                },
566            ],
567        };
568        let finding = AttributedCloneGroupFinding::with_actions(attributed);
569        assert_eq!(finding.actions.len(), 2);
570        assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
571        assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
572    }
573
574    #[test]
575    fn clone_group_finding_surfaces_dominant_identifier() {
576        let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
577        let g = CloneGroup {
578            instances: vec![
579                CloneInstance {
580                    file: PathBuf::from("/root/a.ts"),
581                    start_line: 1,
582                    end_line: 3,
583                    start_col: 0,
584                    end_col: 0,
585                    fragment: fragment.to_string(),
586                },
587                CloneInstance {
588                    file: PathBuf::from("/root/b.ts"),
589                    start_line: 1,
590                    end_line: 3,
591                    start_col: 0,
592                    end_col: 0,
593                    fragment: fragment.to_string(),
594                },
595            ],
596            token_count: 100,
597            line_count: 3,
598            similarity: None,
599        };
600        let finding = CloneGroupFinding::with_actions(g);
601        assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
602    }
603
604    #[test]
605    fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
606        let finding = CloneGroupFinding::with_actions(group(2));
607        assert!(finding.suggested_name.is_none());
608    }
609
610    #[test]
611    fn clone_group_finding_description_pluralises_instance_count() {
612        let single = CloneGroupFinding::with_actions(group(1));
613        assert!(single.actions[0].description.contains("1 instance"));
614        assert!(!single.actions[0].description.contains("1 instances"));
615        let multi = CloneGroupFinding::with_actions(group(3));
616        assert!(multi.actions[0].description.contains("3 instances"));
617    }
618
619    #[test]
620    fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
621        let family = CloneFamily {
622            files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
623            groups: vec![group(2), group(2)],
624            total_duplicated_lines: 40,
625            total_duplicated_tokens: 200,
626            suggestions: vec![
627                RefactoringSuggestion {
628                    kind: RefactoringKind::ExtractFunction,
629                    description: "Extract helper".to_string(),
630                    estimated_savings: 10,
631                },
632                RefactoringSuggestion {
633                    kind: RefactoringKind::ExtractModule,
634                    description: "Extract module".to_string(),
635                    estimated_savings: 30,
636                },
637            ],
638        };
639        let finding = CloneFamilyFinding::with_actions(family);
640        assert_eq!(finding.actions.len(), 4);
641        assert_eq!(
642            finding.actions[0].kind,
643            CloneFamilyActionType::ExtractShared
644        );
645        assert_eq!(
646            finding.actions[1].kind,
647            CloneFamilyActionType::ApplySuggestion
648        );
649        assert_eq!(finding.actions[1].description, "Extract helper");
650        assert_eq!(
651            finding.actions[2].kind,
652            CloneFamilyActionType::ApplySuggestion
653        );
654        assert_eq!(finding.actions[2].description, "Extract module");
655        assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
656        assert_eq!(finding.groups.len(), 2);
657        for inner in &finding.groups {
658            assert_eq!(inner.actions.len(), 2);
659            assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
660            assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
661        }
662    }
663
664    #[test]
665    fn clone_family_finding_with_no_suggestions_emits_two_actions() {
666        let family = CloneFamily {
667            files: vec![PathBuf::from("/root/a.ts")],
668            groups: vec![group(2)],
669            total_duplicated_lines: 20,
670            total_duplicated_tokens: 100,
671            suggestions: Vec::new(),
672        };
673        let finding = CloneFamilyFinding::with_actions(family);
674        assert_eq!(finding.actions.len(), 2);
675        assert_eq!(
676            finding.actions[0].kind,
677            CloneFamilyActionType::ExtractShared
678        );
679        assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
680    }
681
682    #[test]
683    fn payload_from_report_wraps_all_findings() {
684        let report = DuplicationReport {
685            clone_groups: vec![group(2), group(3)],
686            clone_families: vec![CloneFamily {
687                files: vec![PathBuf::from("/root/a.ts")],
688                groups: vec![group(2)],
689                total_duplicated_lines: 20,
690                total_duplicated_tokens: 100,
691                suggestions: Vec::new(),
692            }],
693            mirrored_directories: Vec::new(),
694            stats: DuplicationStats::default(),
695        };
696        let payload = DupesReportPayload::from_report(&report);
697        assert_eq!(payload.clone_groups.len(), 2);
698        assert_eq!(payload.clone_families.len(), 1);
699        for finding in &payload.clone_groups {
700            assert_eq!(finding.actions.len(), 2);
701        }
702        assert_eq!(payload.clone_families[0].actions.len(), 2);
703    }
704
705    #[test]
706    fn duplication_codeclimate_uses_relative_normalized_paths() {
707        let report = DuplicationReport {
708            clone_groups: vec![CloneGroup {
709                instances: vec![CloneInstance {
710                    file: PathBuf::from("/root/app/[id]/page.tsx"),
711                    start_line: 4,
712                    end_line: 8,
713                    start_col: 0,
714                    end_col: 0,
715                    fragment: "const duplicate = 1;".to_string(),
716                }],
717                token_count: 42,
718                line_count: 5,
719                similarity: None,
720            }],
721            clone_families: Vec::new(),
722            mirrored_directories: Vec::new(),
723            stats: DuplicationStats::default(),
724        };
725
726        let issues = build_duplication_codeclimate(&report, Path::new("/root"));
727
728        assert_eq!(issues.len(), 1);
729        let issue = &issues[0];
730        assert_eq!(issue.check_name, "fallow/code-duplication");
731        assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
732        assert_eq!(issue.location.lines.begin, 4);
733        assert_eq!(issue.categories, vec!["Duplication"]);
734        assert!(issue.description.starts_with("Code clone dup:"));
735        assert_eq!(issue.location.lines.end, Some(8));
736    }
737}