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