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
151impl DuplicationGroup {
152    /// Drop the verbatim source text from every clone instance in this bucket,
153    /// including the copies nested in `clone_families[].groups[]`.
154    pub fn strip_fragments(&mut self) {
155        for finding in &mut self.clone_groups {
156            for instance in &mut finding.group.instances {
157                instance.instance.fragment.clear();
158            }
159        }
160        for family in &mut self.clone_families {
161            for finding in &mut family.groups {
162                finding.group.strip_fragments();
163            }
164        }
165    }
166}
167
168/// Wrapper carrying the resolver mode label and grouped buckets.
169#[derive(Debug, Clone, Serialize)]
170pub struct DuplicationGrouping {
171    /// Resolver mode label (`"owner"`, `"directory"`, `"package"`, `"section"`).
172    pub mode: &'static str,
173    /// One bucket per resolver key.
174    pub groups: Vec<DuplicationGroup>,
175}
176
177/// Why the audit new-only gate demoted an introduced clone group to
178/// inherited. Serializes as a kebab-case string on the wire (for example
179/// `"no-added-lines"`).
180///
181/// Further variants may be added in later releases; consumers should treat an
182/// unknown value as "some demotion reason" rather than failing.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185#[serde(rename_all = "kebab-case")]
186pub enum CloneDemotionReason {
187    /// No instance of the group overlaps an added line in the run's diff: the
188    /// group was re-shaped by removals elsewhere in the changeset, not written
189    /// by it (issue #2164).
190    NoAddedLines,
191}
192
193impl CloneDemotionReason {
194    /// The kebab-case wire literal, obtained from the serde representation so
195    /// human output and JSON cannot diverge.
196    #[must_use]
197    pub fn wire_name(self) -> String {
198        match serde_json::to_value(self) {
199            Ok(serde_json::Value::String(name)) => name,
200            _ => String::new(),
201        }
202    }
203}
204
205/// Wire-shape envelope for a [`CloneGroup`] finding. Flattens the bare
206/// group via `#[serde(flatten)]` and carries a typed `actions` array plus
207/// the optional audit-mode `introduced` flag. The typed envelope replaced
208/// the legacy JSON post-pass injection; a guard test in
209/// `crates/cli/src/report/json.rs` rejects any reintroduced post-pass.
210#[derive(Debug, Clone, Serialize)]
211#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
212pub struct CloneGroupFinding {
213    /// The underlying clone group.
214    #[serde(flatten)]
215    pub group: CloneGroup,
216    /// Stable content fingerprint, usually `dup:<8hex>` and widened on rare
217    /// report collisions. Addressable via `fallow dupes --trace dup:<fp>` (and
218    /// the `trace_clone` MCP tool) to deep-dive this group; shown alongside
219    /// each group in the human listing.
220    pub fingerprint: String,
221    /// Maximum directory-tree or same-file line distance between instances.
222    pub spread: usize,
223    /// Best-effort human-readable name for the clone: the dominant repeated
224    /// identifier across the duplicated fragment (e.g. a shared `parseCsv`
225    /// function). `None` when the clone has no clear dominant name (generic or
226    /// tied identifiers); consumers then fall back to a file-based label. Lets
227    /// editors and agents label a clone by what it is rather than an opaque
228    /// ordinal.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub suggested_name: Option<String>,
231    /// Suggested next steps: an `extract-shared` primary and a
232    /// `suppress-line` secondary. Always emitted (possibly empty for
233    /// forward-compat).
234    pub actions: Vec<CloneGroupAction>,
235    /// Set by the audit pass when this clone group is introduced relative
236    /// to the merge-base. `None` when serialized directly from Rust.
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub introduced: Option<AuditIntroduced>,
239    /// Set only by `fallow audit` under `--gate new-only`, on groups whose
240    /// `introduced` flag the gate demoted to `false`: why the demotion
241    /// happened. `None` everywhere else, including `fallow dupes
242    /// --format json` (issue #2220).
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub demotion_reason: Option<CloneDemotionReason>,
245}
246
247impl CloneGroupFinding {
248    /// Build the wrapper from a raw [`CloneGroup`].
249    #[allow(
250        dead_code,
251        reason = "kept for focused wrapper tests and non-report construction paths"
252    )]
253    #[must_use]
254    pub fn with_actions(group: CloneGroup) -> Self {
255        let fingerprint = clone_fingerprint(&group.instances);
256        Self::with_fingerprint(group, fingerprint)
257    }
258
259    /// Build the wrapper with a precomputed report-scoped fingerprint.
260    #[must_use]
261    pub fn with_fingerprint(group: CloneGroup, fingerprint: String) -> Self {
262        let spread = group.spread();
263        let suggested_name = dominant_identifier(&group);
264        let actions = clone_group_actions(group.line_count, group.instances.len());
265        Self {
266            fingerprint,
267            spread,
268            suggested_name,
269            group,
270            actions,
271            introduced: None,
272            demotion_reason: None,
273        }
274    }
275}
276
277/// Wire-shape envelope for a [`CloneFamily`] finding.
278///
279/// Unlike most `*Finding` wrappers this one is NOT `#[serde(flatten)]` over
280/// the bare [`CloneFamily`], because the family's nested
281/// `groups: Vec<CloneGroup>` field needs to carry the typed
282/// `CloneGroupFinding` wrapper too (so every nested clone group gets its
283/// own `actions[]` array, matching the legacy post-pass behavior; see issue
284/// #393 regression test). The wire shape stays byte-identical to the
285/// previous post-pass output. No `introduced` field because `fallow audit`
286/// attributes clone groups (not families) when running against a base ref.
287#[derive(Debug, Clone, Serialize)]
288#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
289pub struct CloneFamilyFinding {
290    /// The files involved in this family.
291    #[serde(serialize_with = "serde_path::serialize_vec")]
292    pub files: Vec<PathBuf>,
293    /// Clone groups belonging to this family, each wrapped with typed
294    /// `actions[]` so consumers that read `clone_families[].groups[]`
295    /// directly see the same shape as the top-level `clone_groups[]`.
296    pub groups: Vec<CloneGroupFinding>,
297    /// Total number of duplicated lines across all groups.
298    pub total_duplicated_lines: usize,
299    /// Total number of duplicated tokens across all groups.
300    pub total_duplicated_tokens: usize,
301    /// Refactoring suggestions for this family.
302    pub suggestions: Vec<RefactoringSuggestion>,
303    /// Suggested next steps: an `extract-shared` primary, one
304    /// `apply-suggestion` per `RefactoringSuggestion` on the family, and
305    /// a trailing `suppress-line`. Always emitted (possibly empty for
306    /// forward-compat).
307    pub actions: Vec<CloneFamilyAction>,
308}
309
310impl CloneFamilyFinding {
311    /// Build the wrapper from a raw [`CloneFamily`].
312    #[allow(
313        dead_code,
314        reason = "kept for focused wrapper tests and non-report construction paths"
315    )]
316    #[must_use]
317    pub fn with_actions(family: CloneFamily) -> Self {
318        let fingerprints = CloneFingerprintSet::from_groups(&family.groups);
319        Self::with_fingerprints(family, &fingerprints)
320    }
321
322    /// Build the wrapper using the report-scoped fingerprint assignment shared
323    /// by all duplication output surfaces.
324    #[must_use]
325    pub fn with_fingerprints(family: CloneFamily, fingerprints: &CloneFingerprintSet) -> Self {
326        let actions = build_clone_family_actions(
327            &family.groups,
328            family.total_duplicated_lines,
329            &family.suggestions,
330        );
331        Self {
332            files: family.files,
333            groups: family
334                .groups
335                .into_iter()
336                .map(|group| {
337                    let fingerprint = fingerprints.fingerprint_for_group(&group);
338                    CloneGroupFinding::with_fingerprint(group, fingerprint)
339                })
340                .collect(),
341            total_duplicated_lines: family.total_duplicated_lines,
342            total_duplicated_tokens: family.total_duplicated_tokens,
343            suggestions: family.suggestions,
344            actions,
345        }
346    }
347}
348
349fn build_clone_family_actions(
350    groups: &[CloneGroup],
351    total_duplicated_lines: usize,
352    suggestions: &[RefactoringSuggestion],
353) -> Vec<CloneFamilyAction> {
354    clone_family_actions(
355        groups.len(),
356        total_duplicated_lines,
357        suggestions
358            .iter()
359            .map(|suggestion| suggestion.description.as_str()),
360    )
361}
362
363/// Wire-shape payload for `fallow dupes --format json` (the body that
364/// flattens into the `DupesOutput` envelope and is also
365/// emitted under the `dupes` / `duplication` key inside the combined and
366/// audit envelopes).
367///
368/// Mirrors [`DuplicationReport`] field-for-field, except `clone_groups`
369/// and `clone_families` carry the typed wrapper envelopes instead of bare
370/// findings, so the schema (and any TS / agent consumer) sees the typed
371/// `actions[]` natively.
372#[derive(Debug, Clone, Serialize)]
373#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
374pub struct DupesReportPayload {
375    /// All detected clone groups, each wrapped with typed actions.
376    pub clone_groups: Vec<CloneGroupFinding>,
377    /// Clone families, each wrapped with typed actions. Inner `groups`
378    /// inside each `CloneFamilyFinding` are themselves wrapped as
379    /// `CloneGroupFinding` entries carrying their own `actions[]` (and
380    /// optional audit-mode `introduced` flag), so JSON-Schema strict
381    /// consumers and TS consumers reading `clone_families[].groups[]` see
382    /// the same shape as the top-level `clone_groups[]` array (preserves
383    /// the issue #393 regression contract).
384    pub clone_families: Vec<CloneFamilyFinding>,
385    /// Mirrored directory pairs.
386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
387    pub mirrored_directories: Vec<MirroredDirectory>,
388    /// Aggregate duplication statistics.
389    pub stats: DuplicationStats,
390}
391
392impl DupesReportPayload {
393    /// Build the payload from a bare [`DuplicationReport`].
394    #[must_use]
395    pub fn from_report(report: &DuplicationReport) -> Self {
396        let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
397        Self {
398            clone_groups: report
399                .clone_groups
400                .iter()
401                .map(|group| {
402                    CloneGroupFinding::with_fingerprint(
403                        group.clone(),
404                        fingerprints.fingerprint_for_group(group),
405                    )
406                })
407                .collect(),
408            clone_families: report
409                .clone_families
410                .iter()
411                .map(|family| CloneFamilyFinding::with_fingerprints(family.clone(), &fingerprints))
412                .collect(),
413            mirrored_directories: report.mirrored_directories.clone(),
414            stats: report.stats.clone(),
415        }
416    }
417
418    /// Build the payload and drop the verbatim source text when the caller
419    /// asked for a location-only body.
420    ///
421    /// Fingerprints, suggested names and actions are computed by
422    /// [`Self::from_report`], so the text is only removed after every consumer
423    /// that reads it has run.
424    #[must_use]
425    pub fn from_report_with_fragments(report: &DuplicationReport, include_fragments: bool) -> Self {
426        let mut payload = Self::from_report(report);
427        if !include_fragments {
428            payload.strip_fragments();
429        }
430        payload
431    }
432
433    /// Drop the verbatim source text from every clone instance, including the
434    /// copies nested in `clone_families[].groups[]`.
435    pub fn strip_fragments(&mut self) {
436        for finding in &mut self.clone_groups {
437            finding.group.strip_fragments();
438        }
439        for family in &mut self.clone_families {
440            for finding in &mut family.groups {
441                finding.group.strip_fragments();
442            }
443        }
444    }
445}
446
447/// Build CodeClimate issues from duplication analysis results.
448///
449/// `fallow-output` owns the CodeClimate wire DTOs. This API layer combines
450/// those DTOs with the engine-owned duplication report so CLI and future
451/// embedders can share the same issue construction policy.
452#[must_use]
453#[expect(
454    clippy::cast_possible_truncation,
455    reason = "line numbers are bounded by source size"
456)]
457pub fn build_duplication_codeclimate(
458    report: &DuplicationReport,
459    root: &Path,
460) -> Vec<CodeClimateIssue> {
461    let mut issues = Vec::new();
462    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
463
464    for group in &report.clone_groups {
465        let clone_fingerprint = fingerprints.fingerprint_for_group(group);
466        let token_str = group.token_count.to_string();
467        let line_count_str = group.line_count.to_string();
468        let fragment_prefix: String = group
469            .instances
470            .first()
471            .map(|inst| inst.fragment.chars().take(64).collect())
472            .unwrap_or_default();
473
474        for (instance_index, instance) in group.instances.iter().enumerate() {
475            let path = codeclimate_path(&instance.file, root);
476            let start_str = instance.start_line.to_string();
477            let fp = codeclimate_fingerprint_hash(&[
478                "fallow/code-duplication",
479                &path,
480                &start_str,
481                &token_str,
482                &line_count_str,
483                &fragment_prefix,
484            ]);
485            let mut issue = fallow_output::build_codeclimate_issue(CodeClimateIssueInput {
486                check_name: "fallow/code-duplication",
487                description: &format!(
488                    "Code clone {clone_fingerprint} ({} lines, {} instances)",
489                    group.line_count,
490                    group.instances.len()
491                ),
492                severity: CodeClimateSeverity::Minor,
493                category: "Duplication",
494                path: &path,
495                begin_line: Some(instance.start_line as u32),
496                fingerprint: &fp,
497            });
498            issue.location.lines.end = Some(instance.end_line as u32);
499            issue.other_locations = group
500                .instances
501                .iter()
502                .enumerate()
503                .filter(|(peer_index, _)| *peer_index != instance_index)
504                .map(|(_, peer)| fallow_output::CodeClimateLocation {
505                    path: codeclimate_path(&peer.file, root),
506                    lines: fallow_output::CodeClimateLines {
507                        begin: peer.start_line as u32,
508                        end: Some(peer.end_line as u32),
509                    },
510                })
511                .collect();
512            issue.other_locations.sort_by(|a, b| {
513                (&a.path, a.lines.begin, a.lines.end).cmp(&(&b.path, b.lines.begin, b.lines.end))
514            });
515            issues.push(issue);
516        }
517    }
518
519    issues
520}
521
522fn codeclimate_path(path: &Path, root: &Path) -> String {
523    normalize_uri(
524        &path
525            .strip_prefix(root)
526            .unwrap_or(path)
527            .display()
528            .to_string(),
529    )
530}
531
532#[cfg(test)]
533mod tests {
534    use std::path::Path;
535
536    use fallow_output::{CloneFamilyActionType, CloneGroupActionType};
537    use fallow_types::duplicates::{
538        CloneInstance, DuplicationStats, RefactoringKind, RefactoringSuggestion,
539    };
540
541    use super::*;
542
543    fn instance(path: &str) -> CloneInstance {
544        CloneInstance {
545            file: PathBuf::from(path),
546            start_line: 1,
547            end_line: 10,
548            start_col: 0,
549            end_col: 0,
550            fragment: String::new(),
551        }
552    }
553
554    fn group(instances: usize) -> CloneGroup {
555        CloneGroup {
556            instances: (0..instances)
557                .map(|i| instance(&format!("/root/file_{i}.ts")))
558                .collect(),
559            token_count: 100,
560            line_count: 20,
561            similarity: None,
562        }
563    }
564
565    #[test]
566    fn clone_group_finding_position_0_is_extract_shared() {
567        let finding = CloneGroupFinding::with_actions(group(2));
568        assert_eq!(finding.actions.len(), 2);
569        assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
570        assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
571        assert!(finding.introduced.is_none());
572        assert!(finding.demotion_reason.is_none());
573    }
574
575    #[test]
576    fn clone_group_finding_omits_audit_only_fields_outside_audit() {
577        // `fallow dupes --format json` serializes findings straight from Rust;
578        // the audit-only `introduced` / `demotion_reason` keys must not appear.
579        let finding = CloneGroupFinding::with_actions(group(2));
580        let value = serde_json::to_value(&finding).expect("finding serializes");
581        assert!(value.get("introduced").is_none());
582        assert!(value.get("demotion_reason").is_none());
583    }
584
585    #[test]
586    fn suppressed_fragments_leave_locations_and_fingerprints_intact() {
587        let mut with_text = group(2);
588        for (index, instance) in with_text.instances.iter_mut().enumerate() {
589            instance.fragment = format!("const shared{index} = compute(input);");
590        }
591        let family = CloneFamily {
592            files: vec![PathBuf::from("/root/file_0.ts")],
593            groups: vec![with_text.clone()],
594            total_duplicated_lines: 20,
595            total_duplicated_tokens: 100,
596            suggestions: Vec::new(),
597        };
598        let report = DuplicationReport {
599            clone_groups: vec![with_text],
600            clone_families: vec![family],
601            mirrored_directories: Vec::new(),
602            stats: DuplicationStats::default(),
603        };
604
605        let kept = DupesReportPayload::from_report_with_fragments(&report, true);
606        let dropped = DupesReportPayload::from_report_with_fragments(&report, false);
607
608        assert_eq!(
609            kept.clone_groups[0].fingerprint, dropped.clone_groups[0].fingerprint,
610            "fingerprints are computed before the text is dropped"
611        );
612
613        let kept_value = serde_json::to_value(&kept).expect("payload serializes");
614        assert!(kept_value["clone_groups"][0]["instances"][0]["fragment"].is_string());
615
616        let value = serde_json::to_value(&dropped).expect("payload serializes");
617        let instance = &value["clone_groups"][0]["instances"][0];
618        assert!(
619            instance.get("fragment").is_none(),
620            "the verbatim source text must be absent, not empty"
621        );
622        assert!(instance.get("file").is_some());
623        assert!(instance.get("start_line").is_some());
624        assert!(instance.get("end_col").is_some());
625        assert!(
626            value["clone_families"][0]["groups"][0]["instances"][0]
627                .get("fragment")
628                .is_none(),
629            "nested clone-family copies carry the same text and must be dropped too"
630        );
631    }
632
633    #[test]
634    fn clone_demotion_reason_wire_name_matches_serde_representation() {
635        let reason = CloneDemotionReason::NoAddedLines;
636        assert_eq!(
637            serde_json::to_value(reason).expect("reason serializes"),
638            serde_json::Value::String(reason.wire_name())
639        );
640        assert_eq!(reason.wire_name(), "no-added-lines");
641    }
642
643    #[test]
644    fn attributed_clone_group_finding_actions_match_clone_group_shape() {
645        let attributed = AttributedCloneGroup {
646            primary_owner: "src".to_string(),
647            token_count: 100,
648            line_count: 20,
649            similarity: None,
650            instances: vec![
651                AttributedInstance {
652                    instance: instance("/root/src/a.ts"),
653                    owner: "src".to_string(),
654                },
655                AttributedInstance {
656                    instance: instance("/root/src/b.ts"),
657                    owner: "src".to_string(),
658                },
659            ],
660        };
661        let finding = AttributedCloneGroupFinding::with_actions(attributed);
662        assert_eq!(finding.actions.len(), 2);
663        assert_eq!(finding.actions[0].kind, CloneGroupActionType::ExtractShared);
664        assert_eq!(finding.actions[1].kind, CloneGroupActionType::SuppressLine);
665    }
666
667    #[test]
668    fn clone_group_finding_surfaces_dominant_identifier() {
669        let fragment = "function parseCsv() { parseCsv(); parseCsv(); return parseCsv; }";
670        let g = CloneGroup {
671            instances: vec![
672                CloneInstance {
673                    file: PathBuf::from("/root/a.ts"),
674                    start_line: 1,
675                    end_line: 3,
676                    start_col: 0,
677                    end_col: 0,
678                    fragment: fragment.to_string(),
679                },
680                CloneInstance {
681                    file: PathBuf::from("/root/b.ts"),
682                    start_line: 1,
683                    end_line: 3,
684                    start_col: 0,
685                    end_col: 0,
686                    fragment: fragment.to_string(),
687                },
688            ],
689            token_count: 100,
690            line_count: 3,
691            similarity: None,
692        };
693        let finding = CloneGroupFinding::with_actions(g);
694        assert_eq!(finding.suggested_name.as_deref(), Some("parseCsv"));
695    }
696
697    #[test]
698    fn clone_group_finding_suggested_name_none_for_unnamed_fragment() {
699        let finding = CloneGroupFinding::with_actions(group(2));
700        assert!(finding.suggested_name.is_none());
701    }
702
703    #[test]
704    fn clone_group_finding_description_pluralises_instance_count() {
705        let single = CloneGroupFinding::with_actions(group(1));
706        assert!(single.actions[0].description.contains("1 instance"));
707        assert!(!single.actions[0].description.contains("1 instances"));
708        let multi = CloneGroupFinding::with_actions(group(3));
709        assert!(multi.actions[0].description.contains("3 instances"));
710    }
711
712    #[test]
713    fn clone_family_finding_position_0_is_extract_shared_then_suggestions_then_suppress() {
714        let family = CloneFamily {
715            files: vec![PathBuf::from("/root/a.ts"), PathBuf::from("/root/b.ts")],
716            groups: vec![group(2), group(2)],
717            total_duplicated_lines: 40,
718            total_duplicated_tokens: 200,
719            suggestions: vec![
720                RefactoringSuggestion {
721                    kind: RefactoringKind::ExtractFunction,
722                    description: "Extract helper".to_string(),
723                    estimated_savings: 10,
724                },
725                RefactoringSuggestion {
726                    kind: RefactoringKind::ExtractModule,
727                    description: "Extract module".to_string(),
728                    estimated_savings: 30,
729                },
730            ],
731        };
732        let finding = CloneFamilyFinding::with_actions(family);
733        assert_eq!(finding.actions.len(), 4);
734        assert_eq!(
735            finding.actions[0].kind,
736            CloneFamilyActionType::ExtractShared
737        );
738        assert_eq!(
739            finding.actions[1].kind,
740            CloneFamilyActionType::ApplySuggestion
741        );
742        assert_eq!(finding.actions[1].description, "Extract helper");
743        assert_eq!(
744            finding.actions[2].kind,
745            CloneFamilyActionType::ApplySuggestion
746        );
747        assert_eq!(finding.actions[2].description, "Extract module");
748        assert_eq!(finding.actions[3].kind, CloneFamilyActionType::SuppressLine);
749        assert_eq!(finding.groups.len(), 2);
750        for inner in &finding.groups {
751            assert_eq!(inner.actions.len(), 2);
752            assert_eq!(inner.actions[0].kind, CloneGroupActionType::ExtractShared);
753            assert_eq!(inner.actions[1].kind, CloneGroupActionType::SuppressLine);
754        }
755    }
756
757    #[test]
758    fn clone_family_finding_with_no_suggestions_emits_two_actions() {
759        let family = CloneFamily {
760            files: vec![PathBuf::from("/root/a.ts")],
761            groups: vec![group(2)],
762            total_duplicated_lines: 20,
763            total_duplicated_tokens: 100,
764            suggestions: Vec::new(),
765        };
766        let finding = CloneFamilyFinding::with_actions(family);
767        assert_eq!(finding.actions.len(), 2);
768        assert_eq!(
769            finding.actions[0].kind,
770            CloneFamilyActionType::ExtractShared
771        );
772        assert_eq!(finding.actions[1].kind, CloneFamilyActionType::SuppressLine);
773    }
774
775    #[test]
776    fn payload_from_report_wraps_all_findings() {
777        let report = DuplicationReport {
778            clone_groups: vec![group(2), group(3)],
779            clone_families: vec![CloneFamily {
780                files: vec![PathBuf::from("/root/a.ts")],
781                groups: vec![group(2)],
782                total_duplicated_lines: 20,
783                total_duplicated_tokens: 100,
784                suggestions: Vec::new(),
785            }],
786            mirrored_directories: Vec::new(),
787            stats: DuplicationStats::default(),
788        };
789        let payload = DupesReportPayload::from_report(&report);
790        assert_eq!(payload.clone_groups.len(), 2);
791        assert_eq!(payload.clone_families.len(), 1);
792        for finding in &payload.clone_groups {
793            assert_eq!(finding.actions.len(), 2);
794        }
795        assert_eq!(payload.clone_families[0].actions.len(), 2);
796    }
797
798    #[test]
799    fn duplication_codeclimate_uses_relative_normalized_paths() {
800        let report = DuplicationReport {
801            clone_groups: vec![CloneGroup {
802                instances: vec![CloneInstance {
803                    file: PathBuf::from("/root/app/[id]/page.tsx"),
804                    start_line: 4,
805                    end_line: 8,
806                    start_col: 0,
807                    end_col: 0,
808                    fragment: "const duplicate = 1;".to_string(),
809                }],
810                token_count: 42,
811                line_count: 5,
812                similarity: None,
813            }],
814            clone_families: Vec::new(),
815            mirrored_directories: Vec::new(),
816            stats: DuplicationStats::default(),
817        };
818
819        let issues = build_duplication_codeclimate(&report, Path::new("/root"));
820
821        assert_eq!(issues.len(), 1);
822        let issue = &issues[0];
823        assert_eq!(issue.check_name, "fallow/code-duplication");
824        assert_eq!(issue.location.path, "app/%5Bid%5D/page.tsx");
825        assert_eq!(issue.location.lines.begin, 4);
826        assert_eq!(issue.categories, vec!["Duplication"]);
827        assert!(issue.description.starts_with("Code clone dup:"));
828        assert_eq!(issue.location.lines.end, Some(8));
829    }
830}