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