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