Skip to main content

fallow_cli/report/
codeclimate.rs

1use std::path::Path;
2use std::process::ExitCode;
3
4use fallow_config::{RulesConfig, Severity};
5use fallow_core::duplicates::DuplicationReport;
6use fallow_core::results::AnalysisResults;
7
8use super::{emit_json, normalize_uri, relative_path};
9use crate::health_types::{ExceededThreshold, HealthReport};
10
11/// Map fallow severity to CodeClimate severity.
12const fn severity_to_codeclimate(s: Severity) -> &'static str {
13    match s {
14        Severity::Error => "major",
15        Severity::Warn | Severity::Off => "minor",
16    }
17}
18
19/// Compute a relative path string with forward-slash normalization.
20///
21/// Uses `normalize_uri` to ensure forward slashes on all platforms
22/// and percent-encode brackets for Next.js dynamic routes.
23fn cc_path(path: &Path, root: &Path) -> String {
24    normalize_uri(&relative_path(path, root).display().to_string())
25}
26
27/// Compute a deterministic fingerprint hash from key fields.
28///
29/// Uses FNV-1a (64-bit) for guaranteed cross-version stability.
30/// `DefaultHasher` is explicitly not specified across Rust versions.
31fn fingerprint_hash(parts: &[&str]) -> String {
32    let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV offset basis
33    for part in parts {
34        for byte in part.bytes() {
35            hash ^= u64::from(byte);
36            hash = hash.wrapping_mul(0x0100_0000_01b3); // FNV prime
37        }
38        // Separator between parts to avoid "ab"+"c" == "a"+"bc"
39        hash ^= 0xff;
40        hash = hash.wrapping_mul(0x0100_0000_01b3);
41    }
42    format!("{hash:016x}")
43}
44
45/// Build a single CodeClimate issue object.
46fn cc_issue(
47    check_name: &str,
48    description: &str,
49    severity: &str,
50    category: &str,
51    path: &str,
52    begin_line: Option<u32>,
53    fingerprint: &str,
54) -> serde_json::Value {
55    let lines = match begin_line {
56        Some(line) => serde_json::json!({ "begin": line }),
57        None => serde_json::json!({ "begin": 1 }),
58    };
59
60    serde_json::json!({
61        "type": "issue",
62        "check_name": check_name,
63        "description": description,
64        "categories": [category],
65        "severity": severity,
66        "fingerprint": fingerprint,
67        "location": {
68            "path": path,
69            "lines": lines
70        }
71    })
72}
73
74/// Build CodeClimate JSON array from dead-code analysis results.
75pub fn build_codeclimate(
76    results: &AnalysisResults,
77    root: &Path,
78    rules: &RulesConfig,
79) -> serde_json::Value {
80    let mut issues = Vec::new();
81
82    // Unused files
83    let level = severity_to_codeclimate(rules.unused_files);
84    for file in &results.unused_files {
85        let path = cc_path(&file.path, root);
86        let fp = fingerprint_hash(&["fallow/unused-file", &path]);
87        issues.push(cc_issue(
88            "fallow/unused-file",
89            "File is not reachable from any entry point",
90            level,
91            "Bug Risk",
92            &path,
93            None,
94            &fp,
95        ));
96    }
97
98    // Unused exports
99    let level = severity_to_codeclimate(rules.unused_exports);
100    for export in &results.unused_exports {
101        let path = cc_path(&export.path, root);
102        let kind = if export.is_re_export {
103            "Re-export"
104        } else {
105            "Export"
106        };
107        let line_str = export.line.to_string();
108        let fp = fingerprint_hash(&[
109            "fallow/unused-export",
110            &path,
111            &line_str,
112            &export.export_name,
113        ]);
114        issues.push(cc_issue(
115            "fallow/unused-export",
116            &format!(
117                "{kind} '{}' is never imported by other modules",
118                export.export_name
119            ),
120            level,
121            "Bug Risk",
122            &path,
123            Some(export.line),
124            &fp,
125        ));
126    }
127
128    // Unused types
129    let level = severity_to_codeclimate(rules.unused_types);
130    for export in &results.unused_types {
131        let path = cc_path(&export.path, root);
132        let kind = if export.is_re_export {
133            "Type re-export"
134        } else {
135            "Type export"
136        };
137        let line_str = export.line.to_string();
138        let fp = fingerprint_hash(&["fallow/unused-type", &path, &line_str, &export.export_name]);
139        issues.push(cc_issue(
140            "fallow/unused-type",
141            &format!(
142                "{kind} '{}' is never imported by other modules",
143                export.export_name
144            ),
145            level,
146            "Bug Risk",
147            &path,
148            Some(export.line),
149            &fp,
150        ));
151    }
152
153    // Unused dependencies (shared closure for all dep locations)
154    let push_deps = |issues: &mut Vec<serde_json::Value>,
155                     deps: &[fallow_core::results::UnusedDependency],
156                     rule_id: &str,
157                     location_label: &str,
158                     severity: Severity| {
159        let level = severity_to_codeclimate(severity);
160        for dep in deps {
161            let path = cc_path(&dep.path, root);
162            let line = if dep.line > 0 { Some(dep.line) } else { None };
163            let fp = fingerprint_hash(&[rule_id, &dep.package_name]);
164            issues.push(cc_issue(
165                rule_id,
166                &format!(
167                    "Package '{}' is in {location_label} but never imported",
168                    dep.package_name
169                ),
170                level,
171                "Bug Risk",
172                &path,
173                line,
174                &fp,
175            ));
176        }
177    };
178
179    push_deps(
180        &mut issues,
181        &results.unused_dependencies,
182        "fallow/unused-dependency",
183        "dependencies",
184        rules.unused_dependencies,
185    );
186    push_deps(
187        &mut issues,
188        &results.unused_dev_dependencies,
189        "fallow/unused-dev-dependency",
190        "devDependencies",
191        rules.unused_dev_dependencies,
192    );
193    push_deps(
194        &mut issues,
195        &results.unused_optional_dependencies,
196        "fallow/unused-optional-dependency",
197        "optionalDependencies",
198        rules.unused_optional_dependencies,
199    );
200
201    // Type-only dependencies
202    let level = severity_to_codeclimate(rules.type_only_dependencies);
203    for dep in &results.type_only_dependencies {
204        let path = cc_path(&dep.path, root);
205        let line = if dep.line > 0 { Some(dep.line) } else { None };
206        let fp = fingerprint_hash(&["fallow/type-only-dependency", &dep.package_name]);
207        issues.push(cc_issue(
208            "fallow/type-only-dependency",
209            &format!(
210                "Package '{}' is only imported via type-only imports (consider moving to devDependencies)",
211                dep.package_name
212            ),
213            level,
214            "Bug Risk",
215            &path,
216            line,
217            &fp,
218        ));
219    }
220
221    // Unused enum members
222    let level = severity_to_codeclimate(rules.unused_enum_members);
223    for member in &results.unused_enum_members {
224        let path = cc_path(&member.path, root);
225        let line_str = member.line.to_string();
226        let fp = fingerprint_hash(&[
227            "fallow/unused-enum-member",
228            &path,
229            &line_str,
230            &member.parent_name,
231            &member.member_name,
232        ]);
233        issues.push(cc_issue(
234            "fallow/unused-enum-member",
235            &format!(
236                "Enum member '{}.{}' is never referenced",
237                member.parent_name, member.member_name
238            ),
239            level,
240            "Bug Risk",
241            &path,
242            Some(member.line),
243            &fp,
244        ));
245    }
246
247    // Unused class members
248    let level = severity_to_codeclimate(rules.unused_class_members);
249    for member in &results.unused_class_members {
250        let path = cc_path(&member.path, root);
251        let line_str = member.line.to_string();
252        let fp = fingerprint_hash(&[
253            "fallow/unused-class-member",
254            &path,
255            &line_str,
256            &member.parent_name,
257            &member.member_name,
258        ]);
259        issues.push(cc_issue(
260            "fallow/unused-class-member",
261            &format!(
262                "Class member '{}.{}' is never referenced",
263                member.parent_name, member.member_name
264            ),
265            level,
266            "Bug Risk",
267            &path,
268            Some(member.line),
269            &fp,
270        ));
271    }
272
273    // Unresolved imports
274    let level = severity_to_codeclimate(rules.unresolved_imports);
275    for import in &results.unresolved_imports {
276        let path = cc_path(&import.path, root);
277        let line_str = import.line.to_string();
278        let fp = fingerprint_hash(&[
279            "fallow/unresolved-import",
280            &path,
281            &line_str,
282            &import.specifier,
283        ]);
284        issues.push(cc_issue(
285            "fallow/unresolved-import",
286            &format!("Import '{}' could not be resolved", import.specifier),
287            level,
288            "Bug Risk",
289            &path,
290            Some(import.line),
291            &fp,
292        ));
293    }
294
295    // Unlisted dependencies — one issue per import site
296    let level = severity_to_codeclimate(rules.unlisted_dependencies);
297    for dep in &results.unlisted_dependencies {
298        for site in &dep.imported_from {
299            let path = cc_path(&site.path, root);
300            let line_str = site.line.to_string();
301            let fp = fingerprint_hash(&[
302                "fallow/unlisted-dependency",
303                &path,
304                &line_str,
305                &dep.package_name,
306            ]);
307            issues.push(cc_issue(
308                "fallow/unlisted-dependency",
309                &format!(
310                    "Package '{}' is imported but not listed in package.json",
311                    dep.package_name
312                ),
313                level,
314                "Bug Risk",
315                &path,
316                Some(site.line),
317                &fp,
318            ));
319        }
320    }
321
322    // Duplicate exports — one issue per location
323    let level = severity_to_codeclimate(rules.duplicate_exports);
324    for dup in &results.duplicate_exports {
325        for loc in &dup.locations {
326            let path = cc_path(&loc.path, root);
327            let line_str = loc.line.to_string();
328            let fp = fingerprint_hash(&[
329                "fallow/duplicate-export",
330                &path,
331                &line_str,
332                &dup.export_name,
333            ]);
334            issues.push(cc_issue(
335                "fallow/duplicate-export",
336                &format!("Export '{}' appears in multiple modules", dup.export_name),
337                level,
338                "Bug Risk",
339                &path,
340                Some(loc.line),
341                &fp,
342            ));
343        }
344    }
345
346    // Circular dependencies
347    let level = severity_to_codeclimate(rules.circular_dependencies);
348    for cycle in &results.circular_dependencies {
349        let Some(first) = cycle.files.first() else {
350            continue;
351        };
352        let path = cc_path(first, root);
353        let chain: Vec<String> = cycle.files.iter().map(|f| cc_path(f, root)).collect();
354        let chain_str = chain.join(":");
355        let fp = fingerprint_hash(&["fallow/circular-dependency", &chain_str]);
356        let line = if cycle.line > 0 {
357            Some(cycle.line)
358        } else {
359            None
360        };
361        issues.push(cc_issue(
362            "fallow/circular-dependency",
363            &format!("Circular dependency: {}", chain.join(" \u{2192} ")),
364            level,
365            "Bug Risk",
366            &path,
367            line,
368            &fp,
369        ));
370    }
371
372    serde_json::Value::Array(issues)
373}
374
375/// Print dead-code analysis results in CodeClimate format.
376pub(super) fn print_codeclimate(
377    results: &AnalysisResults,
378    root: &Path,
379    rules: &RulesConfig,
380) -> ExitCode {
381    let value = build_codeclimate(results, root, rules);
382    emit_json(&value, "CodeClimate")
383}
384
385/// Compute graduated severity for health findings based on threshold ratio.
386///
387/// - 1.0×–1.5× threshold → minor
388/// - 1.5×–2.5× threshold → major
389/// - >2.5× threshold → critical
390fn health_severity(value: u16, threshold: u16) -> &'static str {
391    if threshold == 0 {
392        return "minor";
393    }
394    let ratio = f64::from(value) / f64::from(threshold);
395    if ratio > 2.5 {
396        "critical"
397    } else if ratio > 1.5 {
398        "major"
399    } else {
400        "minor"
401    }
402}
403
404/// Build CodeClimate JSON array from health/complexity analysis results.
405pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
406    let mut issues = Vec::new();
407
408    let cyc_t = report.summary.max_cyclomatic_threshold;
409    let cog_t = report.summary.max_cognitive_threshold;
410
411    for finding in &report.findings {
412        let path = cc_path(&finding.path, root);
413        let description = match finding.exceeded {
414            ExceededThreshold::Both => format!(
415                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
416                finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
417            ),
418            ExceededThreshold::Cyclomatic => format!(
419                "'{}' has cyclomatic complexity {} (threshold: {})",
420                finding.name, finding.cyclomatic, cyc_t
421            ),
422            ExceededThreshold::Cognitive => format!(
423                "'{}' has cognitive complexity {} (threshold: {})",
424                finding.name, finding.cognitive, cog_t
425            ),
426        };
427        let check_name = match finding.exceeded {
428            ExceededThreshold::Both => "fallow/high-complexity",
429            ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
430            ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
431        };
432        // Graduate severity: use the worst exceeded metric
433        let severity = match finding.exceeded {
434            ExceededThreshold::Both => {
435                let cyc_sev = health_severity(finding.cyclomatic, cyc_t);
436                let cog_sev = health_severity(finding.cognitive, cog_t);
437                // Pick the more severe of the two
438                match (cyc_sev, cog_sev) {
439                    ("critical", _) | (_, "critical") => "critical",
440                    ("major", _) | (_, "major") => "major",
441                    _ => "minor",
442                }
443            }
444            ExceededThreshold::Cyclomatic => health_severity(finding.cyclomatic, cyc_t),
445            ExceededThreshold::Cognitive => health_severity(finding.cognitive, cog_t),
446        };
447        let line_str = finding.line.to_string();
448        let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
449        issues.push(cc_issue(
450            check_name,
451            &description,
452            severity,
453            "Complexity",
454            &path,
455            Some(finding.line),
456            &fp,
457        ));
458    }
459
460    serde_json::Value::Array(issues)
461}
462
463/// Print health analysis results in CodeClimate format.
464pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
465    let value = build_health_codeclimate(report, root);
466    emit_json(&value, "CodeClimate")
467}
468
469/// Build CodeClimate JSON array from duplication analysis results.
470pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
471    let mut issues = Vec::new();
472
473    for (i, group) in report.clone_groups.iter().enumerate() {
474        // Content-based fingerprint: hash token_count + line_count + first 64 chars of fragment
475        // This is stable across runs regardless of group ordering.
476        let token_str = group.token_count.to_string();
477        let line_count_str = group.line_count.to_string();
478        let fragment_prefix: String = group
479            .instances
480            .first()
481            .map(|inst| inst.fragment.chars().take(64).collect())
482            .unwrap_or_default();
483
484        for instance in &group.instances {
485            let path = cc_path(&instance.file, root);
486            let start_str = instance.start_line.to_string();
487            let fp = fingerprint_hash(&[
488                "fallow/code-duplication",
489                &path,
490                &start_str,
491                &token_str,
492                &line_count_str,
493                &fragment_prefix,
494            ]);
495            issues.push(cc_issue(
496                "fallow/code-duplication",
497                &format!(
498                    "Code clone group {} ({} lines, {} instances)",
499                    i + 1,
500                    group.line_count,
501                    group.instances.len()
502                ),
503                "minor",
504                "Duplication",
505                &path,
506                Some(instance.start_line as u32),
507                &fp,
508            ));
509        }
510    }
511
512    serde_json::Value::Array(issues)
513}
514
515/// Print duplication analysis results in CodeClimate format.
516pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
517    let value = build_duplication_codeclimate(report, root);
518    emit_json(&value, "CodeClimate")
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::report::test_helpers::sample_results;
525    use fallow_config::RulesConfig;
526    use fallow_core::results::*;
527    use std::path::PathBuf;
528
529    #[test]
530    fn codeclimate_empty_results_produces_empty_array() {
531        let root = PathBuf::from("/project");
532        let results = AnalysisResults::default();
533        let rules = RulesConfig::default();
534        let output = build_codeclimate(&results, &root, &rules);
535        let arr = output.as_array().unwrap();
536        assert!(arr.is_empty());
537    }
538
539    #[test]
540    fn codeclimate_produces_array_of_issues() {
541        let root = PathBuf::from("/project");
542        let results = sample_results(&root);
543        let rules = RulesConfig::default();
544        let output = build_codeclimate(&results, &root, &rules);
545        assert!(output.is_array());
546        let arr = output.as_array().unwrap();
547        // Should have at least one issue per type
548        assert!(!arr.is_empty());
549    }
550
551    #[test]
552    fn codeclimate_issue_has_required_fields() {
553        let root = PathBuf::from("/project");
554        let mut results = AnalysisResults::default();
555        results.unused_files.push(UnusedFile {
556            path: root.join("src/dead.ts"),
557        });
558        let rules = RulesConfig::default();
559        let output = build_codeclimate(&results, &root, &rules);
560        let issue = &output.as_array().unwrap()[0];
561
562        assert_eq!(issue["type"], "issue");
563        assert_eq!(issue["check_name"], "fallow/unused-file");
564        assert!(issue["description"].is_string());
565        assert!(issue["categories"].is_array());
566        assert!(issue["severity"].is_string());
567        assert!(issue["fingerprint"].is_string());
568        assert!(issue["location"].is_object());
569        assert!(issue["location"]["path"].is_string());
570        assert!(issue["location"]["lines"].is_object());
571    }
572
573    #[test]
574    fn codeclimate_unused_file_severity_follows_rules() {
575        let root = PathBuf::from("/project");
576        let mut results = AnalysisResults::default();
577        results.unused_files.push(UnusedFile {
578            path: root.join("src/dead.ts"),
579        });
580
581        // Error severity -> major
582        let rules = RulesConfig::default();
583        let output = build_codeclimate(&results, &root, &rules);
584        assert_eq!(output[0]["severity"], "major");
585
586        // Warn severity -> minor
587        let rules = RulesConfig {
588            unused_files: Severity::Warn,
589            ..RulesConfig::default()
590        };
591        let output = build_codeclimate(&results, &root, &rules);
592        assert_eq!(output[0]["severity"], "minor");
593    }
594
595    #[test]
596    fn codeclimate_unused_export_has_line_number() {
597        let root = PathBuf::from("/project");
598        let mut results = AnalysisResults::default();
599        results.unused_exports.push(UnusedExport {
600            path: root.join("src/utils.ts"),
601            export_name: "helperFn".to_string(),
602            is_type_only: false,
603            line: 10,
604            col: 4,
605            span_start: 120,
606            is_re_export: false,
607        });
608        let rules = RulesConfig::default();
609        let output = build_codeclimate(&results, &root, &rules);
610        let issue = &output[0];
611        assert_eq!(issue["location"]["lines"]["begin"], 10);
612    }
613
614    #[test]
615    fn codeclimate_unused_file_line_defaults_to_1() {
616        let root = PathBuf::from("/project");
617        let mut results = AnalysisResults::default();
618        results.unused_files.push(UnusedFile {
619            path: root.join("src/dead.ts"),
620        });
621        let rules = RulesConfig::default();
622        let output = build_codeclimate(&results, &root, &rules);
623        let issue = &output[0];
624        assert_eq!(issue["location"]["lines"]["begin"], 1);
625    }
626
627    #[test]
628    fn codeclimate_paths_are_relative() {
629        let root = PathBuf::from("/project");
630        let mut results = AnalysisResults::default();
631        results.unused_files.push(UnusedFile {
632            path: root.join("src/deep/nested/file.ts"),
633        });
634        let rules = RulesConfig::default();
635        let output = build_codeclimate(&results, &root, &rules);
636        let path = output[0]["location"]["path"].as_str().unwrap();
637        assert_eq!(path, "src/deep/nested/file.ts");
638        assert!(!path.starts_with("/project"));
639    }
640
641    #[test]
642    fn codeclimate_re_export_label_in_description() {
643        let root = PathBuf::from("/project");
644        let mut results = AnalysisResults::default();
645        results.unused_exports.push(UnusedExport {
646            path: root.join("src/index.ts"),
647            export_name: "reExported".to_string(),
648            is_type_only: false,
649            line: 1,
650            col: 0,
651            span_start: 0,
652            is_re_export: true,
653        });
654        let rules = RulesConfig::default();
655        let output = build_codeclimate(&results, &root, &rules);
656        let desc = output[0]["description"].as_str().unwrap();
657        assert!(desc.contains("Re-export"));
658    }
659
660    #[test]
661    fn codeclimate_unlisted_dep_one_issue_per_import_site() {
662        let root = PathBuf::from("/project");
663        let mut results = AnalysisResults::default();
664        results.unlisted_dependencies.push(UnlistedDependency {
665            package_name: "chalk".to_string(),
666            imported_from: vec![
667                ImportSite {
668                    path: root.join("src/a.ts"),
669                    line: 1,
670                    col: 0,
671                },
672                ImportSite {
673                    path: root.join("src/b.ts"),
674                    line: 5,
675                    col: 0,
676                },
677            ],
678        });
679        let rules = RulesConfig::default();
680        let output = build_codeclimate(&results, &root, &rules);
681        let arr = output.as_array().unwrap();
682        assert_eq!(arr.len(), 2);
683        assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
684        assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
685    }
686
687    #[test]
688    fn codeclimate_duplicate_export_one_issue_per_location() {
689        let root = PathBuf::from("/project");
690        let mut results = AnalysisResults::default();
691        results.duplicate_exports.push(DuplicateExport {
692            export_name: "Config".to_string(),
693            locations: vec![
694                DuplicateLocation {
695                    path: root.join("src/a.ts"),
696                    line: 10,
697                    col: 0,
698                },
699                DuplicateLocation {
700                    path: root.join("src/b.ts"),
701                    line: 20,
702                    col: 0,
703                },
704                DuplicateLocation {
705                    path: root.join("src/c.ts"),
706                    line: 30,
707                    col: 0,
708                },
709            ],
710        });
711        let rules = RulesConfig::default();
712        let output = build_codeclimate(&results, &root, &rules);
713        let arr = output.as_array().unwrap();
714        assert_eq!(arr.len(), 3);
715    }
716
717    #[test]
718    fn codeclimate_circular_dep_emits_chain_in_description() {
719        let root = PathBuf::from("/project");
720        let mut results = AnalysisResults::default();
721        results.circular_dependencies.push(CircularDependency {
722            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
723            length: 2,
724            line: 3,
725            col: 0,
726        });
727        let rules = RulesConfig::default();
728        let output = build_codeclimate(&results, &root, &rules);
729        let desc = output[0]["description"].as_str().unwrap();
730        assert!(desc.contains("Circular dependency"));
731        assert!(desc.contains("src/a.ts"));
732        assert!(desc.contains("src/b.ts"));
733    }
734
735    #[test]
736    fn codeclimate_fingerprints_are_deterministic() {
737        let root = PathBuf::from("/project");
738        let results = sample_results(&root);
739        let rules = RulesConfig::default();
740        let output1 = build_codeclimate(&results, &root, &rules);
741        let output2 = build_codeclimate(&results, &root, &rules);
742
743        let fps1: Vec<&str> = output1
744            .as_array()
745            .unwrap()
746            .iter()
747            .map(|i| i["fingerprint"].as_str().unwrap())
748            .collect();
749        let fps2: Vec<&str> = output2
750            .as_array()
751            .unwrap()
752            .iter()
753            .map(|i| i["fingerprint"].as_str().unwrap())
754            .collect();
755        assert_eq!(fps1, fps2);
756    }
757
758    #[test]
759    fn codeclimate_fingerprints_are_unique() {
760        let root = PathBuf::from("/project");
761        let results = sample_results(&root);
762        let rules = RulesConfig::default();
763        let output = build_codeclimate(&results, &root, &rules);
764
765        let mut fps: Vec<&str> = output
766            .as_array()
767            .unwrap()
768            .iter()
769            .map(|i| i["fingerprint"].as_str().unwrap())
770            .collect();
771        let original_len = fps.len();
772        fps.sort_unstable();
773        fps.dedup();
774        assert_eq!(fps.len(), original_len, "fingerprints should be unique");
775    }
776
777    #[test]
778    fn codeclimate_type_only_dep_has_correct_check_name() {
779        let root = PathBuf::from("/project");
780        let mut results = AnalysisResults::default();
781        results.type_only_dependencies.push(TypeOnlyDependency {
782            package_name: "zod".to_string(),
783            path: root.join("package.json"),
784            line: 8,
785        });
786        let rules = RulesConfig::default();
787        let output = build_codeclimate(&results, &root, &rules);
788        assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
789        let desc = output[0]["description"].as_str().unwrap();
790        assert!(desc.contains("zod"));
791        assert!(desc.contains("type-only"));
792    }
793
794    #[test]
795    fn codeclimate_dep_with_zero_line_omits_line_number() {
796        let root = PathBuf::from("/project");
797        let mut results = AnalysisResults::default();
798        results.unused_dependencies.push(UnusedDependency {
799            package_name: "lodash".to_string(),
800            location: DependencyLocation::Dependencies,
801            path: root.join("package.json"),
802            line: 0,
803        });
804        let rules = RulesConfig::default();
805        let output = build_codeclimate(&results, &root, &rules);
806        // Line 0 -> begin defaults to 1
807        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
808    }
809
810    // ── fingerprint_hash tests ─────────────────────────────────────
811
812    #[test]
813    fn fingerprint_hash_different_inputs_differ() {
814        let h1 = fingerprint_hash(&["a", "b"]);
815        let h2 = fingerprint_hash(&["a", "c"]);
816        assert_ne!(h1, h2);
817    }
818
819    #[test]
820    fn fingerprint_hash_order_matters() {
821        let h1 = fingerprint_hash(&["a", "b"]);
822        let h2 = fingerprint_hash(&["b", "a"]);
823        assert_ne!(h1, h2);
824    }
825
826    #[test]
827    fn fingerprint_hash_separator_prevents_collision() {
828        // "ab" + "c" should differ from "a" + "bc"
829        let h1 = fingerprint_hash(&["ab", "c"]);
830        let h2 = fingerprint_hash(&["a", "bc"]);
831        assert_ne!(h1, h2);
832    }
833
834    #[test]
835    fn fingerprint_hash_is_16_hex_chars() {
836        let h = fingerprint_hash(&["test"]);
837        assert_eq!(h.len(), 16);
838        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
839    }
840
841    // ── severity_to_codeclimate ─────────────────────────────────────
842
843    #[test]
844    fn severity_error_maps_to_major() {
845        assert_eq!(severity_to_codeclimate(Severity::Error), "major");
846    }
847
848    #[test]
849    fn severity_warn_maps_to_minor() {
850        assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
851    }
852
853    #[test]
854    fn severity_off_maps_to_minor() {
855        assert_eq!(severity_to_codeclimate(Severity::Off), "minor");
856    }
857
858    // ── health_severity ─────────────────────────────────────────────
859
860    #[test]
861    fn health_severity_zero_threshold_returns_minor() {
862        assert_eq!(health_severity(100, 0), "minor");
863    }
864
865    #[test]
866    fn health_severity_at_threshold_returns_minor() {
867        assert_eq!(health_severity(10, 10), "minor");
868    }
869
870    #[test]
871    fn health_severity_1_5x_threshold_returns_minor() {
872        assert_eq!(health_severity(15, 10), "minor");
873    }
874
875    #[test]
876    fn health_severity_above_1_5x_returns_major() {
877        assert_eq!(health_severity(16, 10), "major");
878    }
879
880    #[test]
881    fn health_severity_at_2_5x_returns_major() {
882        assert_eq!(health_severity(25, 10), "major");
883    }
884
885    #[test]
886    fn health_severity_above_2_5x_returns_critical() {
887        assert_eq!(health_severity(26, 10), "critical");
888    }
889}