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