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