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