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