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