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    // Boundary violations
401    let level = severity_to_codeclimate(rules.boundary_violation);
402    for v in &results.boundary_violations {
403        let path = cc_path(&v.from_path, root);
404        let to = cc_path(&v.to_path, root);
405        let fp = fingerprint_hash(&["fallow/boundary-violation", &path, &to]);
406        let line = if v.line > 0 { Some(v.line) } else { None };
407        issues.push(cc_issue(
408            "fallow/boundary-violation",
409            &format!(
410                "Boundary violation: {} -> {} ({} -> {})",
411                path, to, v.from_zone, v.to_zone
412            ),
413            level,
414            "Bug Risk",
415            &path,
416            line,
417            &fp,
418        ));
419    }
420
421    serde_json::Value::Array(issues)
422}
423
424/// Print dead-code analysis results in CodeClimate format.
425pub(super) fn print_codeclimate(
426    results: &AnalysisResults,
427    root: &Path,
428    rules: &RulesConfig,
429) -> ExitCode {
430    let value = build_codeclimate(results, root, rules);
431    emit_json(&value, "CodeClimate")
432}
433
434/// Compute graduated severity for health findings based on threshold ratio.
435///
436/// - 1.0×–1.5× threshold → minor
437/// - 1.5×–2.5× threshold → major
438/// - >2.5× threshold → critical
439fn health_severity(value: u16, threshold: u16) -> &'static str {
440    if threshold == 0 {
441        return "minor";
442    }
443    let ratio = f64::from(value) / f64::from(threshold);
444    if ratio > 2.5 {
445        "critical"
446    } else if ratio > 1.5 {
447        "major"
448    } else {
449        "minor"
450    }
451}
452
453/// Build CodeClimate JSON array from health/complexity analysis results.
454#[must_use]
455pub fn build_health_codeclimate(report: &HealthReport, root: &Path) -> serde_json::Value {
456    let mut issues = Vec::new();
457
458    let cyc_t = report.summary.max_cyclomatic_threshold;
459    let cog_t = report.summary.max_cognitive_threshold;
460
461    for finding in &report.findings {
462        let path = cc_path(&finding.path, root);
463        let description = match finding.exceeded {
464            ExceededThreshold::Both => format!(
465                "'{}' has cyclomatic complexity {} (threshold: {}) and cognitive complexity {} (threshold: {})",
466                finding.name, finding.cyclomatic, cyc_t, finding.cognitive, cog_t
467            ),
468            ExceededThreshold::Cyclomatic => format!(
469                "'{}' has cyclomatic complexity {} (threshold: {})",
470                finding.name, finding.cyclomatic, cyc_t
471            ),
472            ExceededThreshold::Cognitive => format!(
473                "'{}' has cognitive complexity {} (threshold: {})",
474                finding.name, finding.cognitive, cog_t
475            ),
476        };
477        let check_name = match finding.exceeded {
478            ExceededThreshold::Both => "fallow/high-complexity",
479            ExceededThreshold::Cyclomatic => "fallow/high-cyclomatic-complexity",
480            ExceededThreshold::Cognitive => "fallow/high-cognitive-complexity",
481        };
482        // Graduate severity: use the worst exceeded metric
483        let severity = match finding.exceeded {
484            ExceededThreshold::Both => {
485                let cyc_sev = health_severity(finding.cyclomatic, cyc_t);
486                let cog_sev = health_severity(finding.cognitive, cog_t);
487                // Pick the more severe of the two
488                match (cyc_sev, cog_sev) {
489                    ("critical", _) | (_, "critical") => "critical",
490                    ("major", _) | (_, "major") => "major",
491                    _ => "minor",
492                }
493            }
494            ExceededThreshold::Cyclomatic => health_severity(finding.cyclomatic, cyc_t),
495            ExceededThreshold::Cognitive => health_severity(finding.cognitive, cog_t),
496        };
497        let line_str = finding.line.to_string();
498        let fp = fingerprint_hash(&[check_name, &path, &line_str, &finding.name]);
499        issues.push(cc_issue(
500            check_name,
501            &description,
502            severity,
503            "Complexity",
504            &path,
505            Some(finding.line),
506            &fp,
507        ));
508    }
509
510    serde_json::Value::Array(issues)
511}
512
513/// Print health analysis results in CodeClimate format.
514pub(super) fn print_health_codeclimate(report: &HealthReport, root: &Path) -> ExitCode {
515    let value = build_health_codeclimate(report, root);
516    emit_json(&value, "CodeClimate")
517}
518
519/// Build CodeClimate JSON array from duplication analysis results.
520#[must_use]
521#[expect(
522    clippy::cast_possible_truncation,
523    reason = "line numbers are bounded by source size"
524)]
525pub fn build_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> serde_json::Value {
526    let mut issues = Vec::new();
527
528    for (i, group) in report.clone_groups.iter().enumerate() {
529        // Content-based fingerprint: hash token_count + line_count + first 64 chars of fragment
530        // This is stable across runs regardless of group ordering.
531        let token_str = group.token_count.to_string();
532        let line_count_str = group.line_count.to_string();
533        let fragment_prefix: String = group
534            .instances
535            .first()
536            .map(|inst| inst.fragment.chars().take(64).collect())
537            .unwrap_or_default();
538
539        for instance in &group.instances {
540            let path = cc_path(&instance.file, root);
541            let start_str = instance.start_line.to_string();
542            let fp = fingerprint_hash(&[
543                "fallow/code-duplication",
544                &path,
545                &start_str,
546                &token_str,
547                &line_count_str,
548                &fragment_prefix,
549            ]);
550            issues.push(cc_issue(
551                "fallow/code-duplication",
552                &format!(
553                    "Code clone group {} ({} lines, {} instances)",
554                    i + 1,
555                    group.line_count,
556                    group.instances.len()
557                ),
558                "minor",
559                "Duplication",
560                &path,
561                Some(instance.start_line as u32),
562                &fp,
563            ));
564        }
565    }
566
567    serde_json::Value::Array(issues)
568}
569
570/// Print duplication analysis results in CodeClimate format.
571pub(super) fn print_duplication_codeclimate(report: &DuplicationReport, root: &Path) -> ExitCode {
572    let value = build_duplication_codeclimate(report, root);
573    emit_json(&value, "CodeClimate")
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use crate::report::test_helpers::sample_results;
580    use fallow_config::RulesConfig;
581    use fallow_core::results::*;
582    use std::path::PathBuf;
583
584    #[test]
585    fn codeclimate_empty_results_produces_empty_array() {
586        let root = PathBuf::from("/project");
587        let results = AnalysisResults::default();
588        let rules = RulesConfig::default();
589        let output = build_codeclimate(&results, &root, &rules);
590        let arr = output.as_array().unwrap();
591        assert!(arr.is_empty());
592    }
593
594    #[test]
595    fn codeclimate_produces_array_of_issues() {
596        let root = PathBuf::from("/project");
597        let results = sample_results(&root);
598        let rules = RulesConfig::default();
599        let output = build_codeclimate(&results, &root, &rules);
600        assert!(output.is_array());
601        let arr = output.as_array().unwrap();
602        // Should have at least one issue per type
603        assert!(!arr.is_empty());
604    }
605
606    #[test]
607    fn codeclimate_issue_has_required_fields() {
608        let root = PathBuf::from("/project");
609        let mut results = AnalysisResults::default();
610        results.unused_files.push(UnusedFile {
611            path: root.join("src/dead.ts"),
612        });
613        let rules = RulesConfig::default();
614        let output = build_codeclimate(&results, &root, &rules);
615        let issue = &output.as_array().unwrap()[0];
616
617        assert_eq!(issue["type"], "issue");
618        assert_eq!(issue["check_name"], "fallow/unused-file");
619        assert!(issue["description"].is_string());
620        assert!(issue["categories"].is_array());
621        assert!(issue["severity"].is_string());
622        assert!(issue["fingerprint"].is_string());
623        assert!(issue["location"].is_object());
624        assert!(issue["location"]["path"].is_string());
625        assert!(issue["location"]["lines"].is_object());
626    }
627
628    #[test]
629    fn codeclimate_unused_file_severity_follows_rules() {
630        let root = PathBuf::from("/project");
631        let mut results = AnalysisResults::default();
632        results.unused_files.push(UnusedFile {
633            path: root.join("src/dead.ts"),
634        });
635
636        // Error severity -> major
637        let rules = RulesConfig::default();
638        let output = build_codeclimate(&results, &root, &rules);
639        assert_eq!(output[0]["severity"], "major");
640
641        // Warn severity -> minor
642        let rules = RulesConfig {
643            unused_files: Severity::Warn,
644            ..RulesConfig::default()
645        };
646        let output = build_codeclimate(&results, &root, &rules);
647        assert_eq!(output[0]["severity"], "minor");
648    }
649
650    #[test]
651    fn codeclimate_unused_export_has_line_number() {
652        let root = PathBuf::from("/project");
653        let mut results = AnalysisResults::default();
654        results.unused_exports.push(UnusedExport {
655            path: root.join("src/utils.ts"),
656            export_name: "helperFn".to_string(),
657            is_type_only: false,
658            line: 10,
659            col: 4,
660            span_start: 120,
661            is_re_export: false,
662        });
663        let rules = RulesConfig::default();
664        let output = build_codeclimate(&results, &root, &rules);
665        let issue = &output[0];
666        assert_eq!(issue["location"]["lines"]["begin"], 10);
667    }
668
669    #[test]
670    fn codeclimate_unused_file_line_defaults_to_1() {
671        let root = PathBuf::from("/project");
672        let mut results = AnalysisResults::default();
673        results.unused_files.push(UnusedFile {
674            path: root.join("src/dead.ts"),
675        });
676        let rules = RulesConfig::default();
677        let output = build_codeclimate(&results, &root, &rules);
678        let issue = &output[0];
679        assert_eq!(issue["location"]["lines"]["begin"], 1);
680    }
681
682    #[test]
683    fn codeclimate_paths_are_relative() {
684        let root = PathBuf::from("/project");
685        let mut results = AnalysisResults::default();
686        results.unused_files.push(UnusedFile {
687            path: root.join("src/deep/nested/file.ts"),
688        });
689        let rules = RulesConfig::default();
690        let output = build_codeclimate(&results, &root, &rules);
691        let path = output[0]["location"]["path"].as_str().unwrap();
692        assert_eq!(path, "src/deep/nested/file.ts");
693        assert!(!path.starts_with("/project"));
694    }
695
696    #[test]
697    fn codeclimate_re_export_label_in_description() {
698        let root = PathBuf::from("/project");
699        let mut results = AnalysisResults::default();
700        results.unused_exports.push(UnusedExport {
701            path: root.join("src/index.ts"),
702            export_name: "reExported".to_string(),
703            is_type_only: false,
704            line: 1,
705            col: 0,
706            span_start: 0,
707            is_re_export: true,
708        });
709        let rules = RulesConfig::default();
710        let output = build_codeclimate(&results, &root, &rules);
711        let desc = output[0]["description"].as_str().unwrap();
712        assert!(desc.contains("Re-export"));
713    }
714
715    #[test]
716    fn codeclimate_unlisted_dep_one_issue_per_import_site() {
717        let root = PathBuf::from("/project");
718        let mut results = AnalysisResults::default();
719        results.unlisted_dependencies.push(UnlistedDependency {
720            package_name: "chalk".to_string(),
721            imported_from: vec![
722                ImportSite {
723                    path: root.join("src/a.ts"),
724                    line: 1,
725                    col: 0,
726                },
727                ImportSite {
728                    path: root.join("src/b.ts"),
729                    line: 5,
730                    col: 0,
731                },
732            ],
733        });
734        let rules = RulesConfig::default();
735        let output = build_codeclimate(&results, &root, &rules);
736        let arr = output.as_array().unwrap();
737        assert_eq!(arr.len(), 2);
738        assert_eq!(arr[0]["check_name"], "fallow/unlisted-dependency");
739        assert_eq!(arr[1]["check_name"], "fallow/unlisted-dependency");
740    }
741
742    #[test]
743    fn codeclimate_duplicate_export_one_issue_per_location() {
744        let root = PathBuf::from("/project");
745        let mut results = AnalysisResults::default();
746        results.duplicate_exports.push(DuplicateExport {
747            export_name: "Config".to_string(),
748            locations: vec![
749                DuplicateLocation {
750                    path: root.join("src/a.ts"),
751                    line: 10,
752                    col: 0,
753                },
754                DuplicateLocation {
755                    path: root.join("src/b.ts"),
756                    line: 20,
757                    col: 0,
758                },
759                DuplicateLocation {
760                    path: root.join("src/c.ts"),
761                    line: 30,
762                    col: 0,
763                },
764            ],
765        });
766        let rules = RulesConfig::default();
767        let output = build_codeclimate(&results, &root, &rules);
768        let arr = output.as_array().unwrap();
769        assert_eq!(arr.len(), 3);
770    }
771
772    #[test]
773    fn codeclimate_circular_dep_emits_chain_in_description() {
774        let root = PathBuf::from("/project");
775        let mut results = AnalysisResults::default();
776        results.circular_dependencies.push(CircularDependency {
777            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
778            length: 2,
779            line: 3,
780            col: 0,
781        });
782        let rules = RulesConfig::default();
783        let output = build_codeclimate(&results, &root, &rules);
784        let desc = output[0]["description"].as_str().unwrap();
785        assert!(desc.contains("Circular dependency"));
786        assert!(desc.contains("src/a.ts"));
787        assert!(desc.contains("src/b.ts"));
788    }
789
790    #[test]
791    fn codeclimate_fingerprints_are_deterministic() {
792        let root = PathBuf::from("/project");
793        let results = sample_results(&root);
794        let rules = RulesConfig::default();
795        let output1 = build_codeclimate(&results, &root, &rules);
796        let output2 = build_codeclimate(&results, &root, &rules);
797
798        let fps1: Vec<&str> = output1
799            .as_array()
800            .unwrap()
801            .iter()
802            .map(|i| i["fingerprint"].as_str().unwrap())
803            .collect();
804        let fps2: Vec<&str> = output2
805            .as_array()
806            .unwrap()
807            .iter()
808            .map(|i| i["fingerprint"].as_str().unwrap())
809            .collect();
810        assert_eq!(fps1, fps2);
811    }
812
813    #[test]
814    fn codeclimate_fingerprints_are_unique() {
815        let root = PathBuf::from("/project");
816        let results = sample_results(&root);
817        let rules = RulesConfig::default();
818        let output = build_codeclimate(&results, &root, &rules);
819
820        let mut fps: Vec<&str> = output
821            .as_array()
822            .unwrap()
823            .iter()
824            .map(|i| i["fingerprint"].as_str().unwrap())
825            .collect();
826        let original_len = fps.len();
827        fps.sort_unstable();
828        fps.dedup();
829        assert_eq!(fps.len(), original_len, "fingerprints should be unique");
830    }
831
832    #[test]
833    fn codeclimate_type_only_dep_has_correct_check_name() {
834        let root = PathBuf::from("/project");
835        let mut results = AnalysisResults::default();
836        results.type_only_dependencies.push(TypeOnlyDependency {
837            package_name: "zod".to_string(),
838            path: root.join("package.json"),
839            line: 8,
840        });
841        let rules = RulesConfig::default();
842        let output = build_codeclimate(&results, &root, &rules);
843        assert_eq!(output[0]["check_name"], "fallow/type-only-dependency");
844        let desc = output[0]["description"].as_str().unwrap();
845        assert!(desc.contains("zod"));
846        assert!(desc.contains("type-only"));
847    }
848
849    #[test]
850    fn codeclimate_dep_with_zero_line_omits_line_number() {
851        let root = PathBuf::from("/project");
852        let mut results = AnalysisResults::default();
853        results.unused_dependencies.push(UnusedDependency {
854            package_name: "lodash".to_string(),
855            location: DependencyLocation::Dependencies,
856            path: root.join("package.json"),
857            line: 0,
858        });
859        let rules = RulesConfig::default();
860        let output = build_codeclimate(&results, &root, &rules);
861        // Line 0 -> begin defaults to 1
862        assert_eq!(output[0]["location"]["lines"]["begin"], 1);
863    }
864
865    // ── fingerprint_hash tests ─────────────────────────────────────
866
867    #[test]
868    fn fingerprint_hash_different_inputs_differ() {
869        let h1 = fingerprint_hash(&["a", "b"]);
870        let h2 = fingerprint_hash(&["a", "c"]);
871        assert_ne!(h1, h2);
872    }
873
874    #[test]
875    fn fingerprint_hash_order_matters() {
876        let h1 = fingerprint_hash(&["a", "b"]);
877        let h2 = fingerprint_hash(&["b", "a"]);
878        assert_ne!(h1, h2);
879    }
880
881    #[test]
882    fn fingerprint_hash_separator_prevents_collision() {
883        // "ab" + "c" should differ from "a" + "bc"
884        let h1 = fingerprint_hash(&["ab", "c"]);
885        let h2 = fingerprint_hash(&["a", "bc"]);
886        assert_ne!(h1, h2);
887    }
888
889    #[test]
890    fn fingerprint_hash_is_16_hex_chars() {
891        let h = fingerprint_hash(&["test"]);
892        assert_eq!(h.len(), 16);
893        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
894    }
895
896    // ── severity_to_codeclimate ─────────────────────────────────────
897
898    #[test]
899    fn severity_error_maps_to_major() {
900        assert_eq!(severity_to_codeclimate(Severity::Error), "major");
901    }
902
903    #[test]
904    fn severity_warn_maps_to_minor() {
905        assert_eq!(severity_to_codeclimate(Severity::Warn), "minor");
906    }
907
908    #[test]
909    fn severity_off_maps_to_minor() {
910        assert_eq!(severity_to_codeclimate(Severity::Off), "minor");
911    }
912
913    // ── health_severity ─────────────────────────────────────────────
914
915    #[test]
916    fn health_severity_zero_threshold_returns_minor() {
917        assert_eq!(health_severity(100, 0), "minor");
918    }
919
920    #[test]
921    fn health_severity_at_threshold_returns_minor() {
922        assert_eq!(health_severity(10, 10), "minor");
923    }
924
925    #[test]
926    fn health_severity_1_5x_threshold_returns_minor() {
927        assert_eq!(health_severity(15, 10), "minor");
928    }
929
930    #[test]
931    fn health_severity_above_1_5x_returns_major() {
932        assert_eq!(health_severity(16, 10), "major");
933    }
934
935    #[test]
936    fn health_severity_at_2_5x_returns_major() {
937        assert_eq!(health_severity(25, 10), "major");
938    }
939
940    #[test]
941    fn health_severity_above_2_5x_returns_critical() {
942        assert_eq!(health_severity(26, 10), "critical");
943    }
944}