1use std::borrow::Cow;
40use std::collections::HashMap;
41use std::fmt::Write as _;
42use std::io::{self, Write};
43
44use serde::Serialize;
45use sha2::{Digest, Sha256};
46
47use crate::diag::warn;
48use crate::metric_catalog::{Threshold, lookup};
49use crate::output::offenders::{OffenderRecord, Severity, TOOL_ID, warn_non_utf8_path};
50
51const FINGERPRINT_BYTE_LEN: usize = 16;
57
58pub fn write_code_climate<W: Write>(offenders: &[OffenderRecord], mut writer: W) -> io::Result<()> {
75 if offenders.is_empty() {
76 return writer.write_all(b"[]\n");
77 }
78 let mut issues: Vec<CodeClimateIssue> = Vec::with_capacity(offenders.len());
79 let mut seen: HashMap<(String, Option<String>, String), u32> = HashMap::new();
89 for record in offenders {
90 let Some(path_raw) = warn_non_utf8_path("code-climate", &record.path) else {
91 continue;
92 };
93 let Some(path) = normalize_path(path_raw) else {
94 warn(format_args!(
95 "skipping empty repo-relative path in code-climate output: {}",
96 record.path.display()
97 ));
98 continue;
99 };
100 let start_line = record.start_line.max(1);
101 let lines_end = (record.end_line > start_line).then_some(record.end_line);
102 let positions = record.start_col.filter(|c| *c > 0).map(|column| Positions {
103 begin: Position {
104 line: start_line,
105 column,
106 },
107 });
108 let key = (path.clone(), record.function.clone(), record.metric.clone());
109 let ordinal = seen.entry(key).or_insert(0);
110 let fingerprint = fingerprint(&path, record.function.as_deref(), &record.metric, *ordinal);
111 *ordinal += 1;
112 issues.push(CodeClimateIssue {
113 description: build_description(record),
114 check_name: format!("{TOOL_ID}/{}", record.metric),
115 fingerprint,
116 severity: severity_band(&record.metric, record.value, record.limit, record.severity),
117 location: Location {
118 path,
119 lines: Lines {
120 begin: start_line,
121 end: lines_end,
122 },
123 positions,
124 },
125 });
126 }
127 serde_json::to_writer(&mut writer, &issues)
128 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
129 writer.write_all(b"\n")
130}
131
132#[derive(Serialize)]
133struct CodeClimateIssue {
134 description: String,
135 check_name: String,
136 fingerprint: String,
137 severity: &'static str,
138 location: Location,
139}
140
141#[derive(Serialize)]
142struct Location {
143 path: String,
144 lines: Lines,
145 #[serde(skip_serializing_if = "Option::is_none")]
146 positions: Option<Positions>,
147}
148
149#[derive(Serialize)]
150struct Lines {
151 begin: u32,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 end: Option<u32>,
154}
155
156#[derive(Serialize)]
157struct Positions {
158 begin: Position,
159}
160
161#[derive(Serialize)]
162struct Position {
163 line: u32,
164 column: u32,
165}
166
167fn severity_band(metric: &str, value: f64, limit: f64, severity: Severity) -> &'static str {
184 let floor = match severity {
192 Severity::Warning => "minor",
193 Severity::Error => "major",
194 };
195 if !value.is_finite() {
200 return floor;
201 }
202 let Ok(checked) = Threshold::check(metric, limit) else {
203 return floor;
204 };
205 if !checked.threshold.breaches(value) {
209 return floor;
210 }
211 let severity = checked.threshold.severity(value);
212 let band = if severity <= 1.5 {
213 "minor"
214 } else if severity <= 2.0 {
215 "major"
216 } else if severity <= 4.0 {
217 "critical"
218 } else {
219 "blocker"
220 };
221 worst_severity(band, floor)
222}
223
224fn severity_rank(level: &str) -> u8 {
230 match level {
231 "minor" => 1,
232 "major" => 2,
233 "critical" => 3,
234 "blocker" => 4,
235 _ => 0,
236 }
237}
238
239fn worst_severity(a: &'static str, b: &'static str) -> &'static str {
241 if severity_rank(a) >= severity_rank(b) {
242 a
243 } else {
244 b
245 }
246}
247
248fn fingerprint(path: &str, function: Option<&str>, metric: &str, ordinal: u32) -> String {
271 let mut h = Sha256::new();
272 h.update(path.as_bytes());
273 h.update(b"\0");
274 h.update(function.unwrap_or("").as_bytes());
275 h.update(b"\0");
276 h.update(metric.as_bytes());
277 if ordinal > 0 {
278 h.update(b"\0");
279 h.update(ordinal.to_le_bytes());
280 }
281 let digest = h.finalize();
282 hex_lower_bytes(&digest[..FINGERPRINT_BYTE_LEN])
283}
284
285fn hex_lower_bytes(bytes: &[u8]) -> String {
290 let mut out = String::with_capacity(bytes.len() * 2);
291 for byte in bytes {
292 let _ = write!(&mut out, "{byte:02x}");
296 }
297 out
298}
299
300fn normalize_path(raw: &str) -> Option<String> {
301 let normalized: Cow<'_, str> = if raw.contains('\\') {
305 Cow::Owned(raw.replace('\\', "/"))
306 } else {
307 Cow::Borrowed(raw)
308 };
309 let stripped = normalized.strip_prefix("./").unwrap_or(&normalized);
310 if stripped.is_empty() {
311 None
312 } else {
313 Some(stripped.to_owned())
314 }
315}
316
317fn build_description(record: &OffenderRecord) -> String {
318 let Some(long_form) = lookup(&record.metric).map(|i| i.long_description) else {
319 return record.default_message();
320 };
321 let tail = record.default_message();
327 let mut out = String::with_capacity(long_form.len() + 1 + tail.len());
328 out.push_str(long_form);
329 out.push(' ');
330 out.push_str(&tail);
331 out
332}
333
334#[cfg(test)]
335#[allow(
336 clippy::float_cmp,
337 clippy::cast_precision_loss,
338 clippy::cast_possible_truncation,
339 clippy::cast_sign_loss,
340 clippy::similar_names,
341 clippy::doc_markdown,
342 clippy::needless_raw_string_hashes,
343 clippy::too_many_lines
344)]
345mod tests {
346 use super::*;
347 use std::path::PathBuf;
348
349 fn rec(path: &str, metric: &str, value: f64, limit: f64) -> OffenderRecord {
350 OffenderRecord {
351 path: PathBuf::from(path),
352 function: Some("f".into()),
353 start_line: 42,
354 end_line: 50,
355 start_col: Some(5),
356 metric: metric.into(),
357 value,
358 limit,
359 severity: Severity::Warning,
360 }
361 }
362
363 fn render(offenders: &[OffenderRecord]) -> String {
364 let mut buf = Vec::new();
365 write_code_climate(offenders, &mut buf).expect("writing to Vec is infallible");
366 String::from_utf8(buf).expect("output is UTF-8")
367 }
368
369 fn render_value(offenders: &[OffenderRecord]) -> serde_json::Value {
370 serde_json::from_str(&render(offenders)).expect("valid JSON")
371 }
372
373 #[test]
374 fn empty_input_emits_bracket_newline() {
375 assert_eq!(render(&[]), "[]\n");
376 }
377
378 #[test]
379 fn single_offender_anchored_snapshot() {
380 let mut r = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
381 r.start_col = None;
382 let v = render_value(&[r]);
383 insta::assert_json_snapshot!(v, @r#"
384 [
385 {
386 "check_name": "big-code-analysis/cyclomatic",
387 "description": "Cyclomatic Complexity exceeds the configured threshold. cyclomatic 17 exceeds limit 15",
388 "fingerprint": "209c41c7caa70e296f0bb82946cce7cc",
389 "location": {
390 "lines": {
391 "begin": 42,
392 "end": 50
393 },
394 "path": "src/foo.rs"
395 },
396 "severity": "minor"
397 }
398 ]
399 "#);
400 }
401
402 #[test]
403 fn multi_offender_with_column_and_file_level() {
404 let with_col = rec("src/a.rs", "cyclomatic", 30.0, 15.0);
405 let mut file_level = rec("src/b.rs", "loc.lloc", 250.0, 100.0);
406 file_level.function = None;
407 file_level.start_col = None;
408 let v = render_value(&[with_col, file_level]);
409 insta::assert_json_snapshot!(v, @r#"
410 [
411 {
412 "check_name": "big-code-analysis/cyclomatic",
413 "description": "Cyclomatic Complexity exceeds the configured threshold. cyclomatic 30 exceeds limit 15",
414 "fingerprint": "03dd26a883d163bd752853e1dd15557d",
415 "location": {
416 "lines": {
417 "begin": 42,
418 "end": 50
419 },
420 "path": "src/a.rs",
421 "positions": {
422 "begin": {
423 "column": 5,
424 "line": 42
425 }
426 }
427 },
428 "severity": "major"
429 },
430 {
431 "check_name": "big-code-analysis/loc.lloc",
432 "description": "Logical lines of code exceed the configured threshold. loc.lloc 250 exceeds limit 100",
433 "fingerprint": "cc3f570c9b909e186681cf36a6cffe5c",
434 "location": {
435 "lines": {
436 "begin": 42,
437 "end": 50
438 },
439 "path": "src/b.rs"
440 },
441 "severity": "critical"
442 }
443 ]
444 "#);
445 }
446
447 #[test]
448 fn severity_band_table_upward_metric() {
449 assert_eq!(
451 severity_band("cyclomatic", 10.0, 10.0, Severity::Warning),
452 "minor"
453 );
454 assert_eq!(
455 severity_band("cyclomatic", 12.5, 10.0, Severity::Warning),
456 "minor"
457 );
458 assert_eq!(
459 severity_band("cyclomatic", 17.5, 10.0, Severity::Warning),
460 "major"
461 );
462 assert_eq!(
463 severity_band("cyclomatic", 30.0, 10.0, Severity::Warning),
464 "critical"
465 );
466 assert_eq!(
468 severity_band("cyclomatic", 20.0, 10.0, Severity::Warning),
469 "major"
470 );
471 assert_eq!(
472 severity_band("cyclomatic", 40.0, 10.0, Severity::Warning),
473 "critical"
474 );
475 assert_eq!(
476 severity_band("cyclomatic", 100.0, 10.0, Severity::Warning),
477 "blocker"
478 );
479 }
480
481 #[test]
482 fn severity_band_table_mi_family_reads_index_units() {
483 let band = |value| severity_band("mi.original", value, 20.0, Severity::Warning);
489 assert_eq!(band(11.45), "minor");
491 assert_eq!(band(11.44), "major");
493 assert_eq!(band(-0.1), "critical");
496 assert_eq!(band(0.1), "critical");
497 assert_eq!(band(0.0), "critical");
499 assert_eq!(band(-40.0), "blocker");
501 assert_eq!(
503 severity_band("mi.original", -80.0, -50.0, Severity::Warning),
504 "critical"
505 );
506 assert_eq!(
509 severity_band("mi.visual_studio", 15.0, 20.0, Severity::Warning),
510 "minor"
511 );
512 assert_eq!(
513 severity_band("mi.visual_studio", 5.0, 20.0, Severity::Warning),
514 "critical"
515 );
516 }
517
518 #[test]
519 fn declared_error_severity_is_a_floor_not_overridden_by_band() {
520 assert_eq!(
525 severity_band("cyclomatic", 11.0, 10.0, Severity::Error),
526 "major",
527 "Error must floor at major even at a sub-1.5x ratio"
528 );
529 assert_eq!(
531 severity_band("cyclomatic", 30.0, 10.0, Severity::Error),
532 "critical"
533 );
534 assert_eq!(
536 severity_band("cyclomatic", 11.0, 10.0, Severity::Warning),
537 "minor"
538 );
539 }
540
541 #[test]
542 fn description_lower_is_worse_uses_falls_below() {
543 let mut r = rec("a.rs", "mi.original", 30.0, 50.0);
547 r.start_col = None;
548 let v = render_value(&[r]);
549 let desc = v[0]["description"].as_str().expect("string");
550 assert!(
551 desc.contains("falls below limit 50"),
552 "mi.* description must say 'falls below limit', got: {desc}"
553 );
554 assert!(
555 desc.starts_with("Maintainability Index falls below the configured threshold."),
556 "mi.* long-form prefix expected, got: {desc}"
557 );
558 }
559
560 #[test]
561 fn severity_band_zero_limit_breach_is_the_top_band() {
562 assert_eq!(
566 severity_band("cyclomatic", 5.0, 0.0, Severity::Warning),
567 "blocker"
568 );
569 assert_eq!(
570 severity_band("cyclomatic", 5.0, 0.0, Severity::Error),
571 "blocker"
572 );
573 }
574
575 #[track_caller]
578 fn assert_keeps_floor(metric: &str, value: f64, limit: f64) {
579 for (severity, floor) in [(Severity::Warning, "minor"), (Severity::Error, "major")] {
580 assert_eq!(
581 severity_band(metric, value, limit, severity),
582 floor,
583 "{metric}: {value} vs {limit}"
584 );
585 }
586 }
587
588 #[test]
589 fn severity_band_non_breaching_record_keeps_its_floor() {
590 for (value, limit) in [(0.0, 0.0), (10.0, 10.0), (3.0, 10.0), (-2.0, 0.0)] {
596 assert_keeps_floor("cyclomatic", value, limit);
597 }
598 assert_keeps_floor("mi.original", 20.0, 20.0);
600 }
601
602 #[test]
603 fn severity_band_falls_back_when_the_record_is_not_a_threshold() {
604 for (metric, limit) in [
608 ("not.a.metric", 10.0),
609 ("cyclomatic", -1.0),
610 ("cyclomatic", f64::NAN),
611 ("mi.original", f64::INFINITY),
612 ] {
613 assert_keeps_floor(metric, 50.0, limit);
614 }
615 }
616
617 #[test]
618 fn severity_band_falls_back_when_value_nan() {
619 assert_eq!(
620 severity_band("cyclomatic", f64::NAN, 10.0, Severity::Warning),
621 "minor"
622 );
623 assert_eq!(
624 severity_band("cyclomatic", f64::NAN, 10.0, Severity::Error),
625 "major"
626 );
627 }
628
629 #[test]
630 fn severity_band_falls_back_when_value_inf() {
631 assert_eq!(
632 severity_band("cyclomatic", f64::INFINITY, 10.0, Severity::Warning),
633 "minor"
634 );
635 assert_eq!(
636 severity_band("cyclomatic", f64::INFINITY, 10.0, Severity::Error),
637 "major"
638 );
639 }
640
641 #[test]
642 fn fingerprint_is_line_value_insensitive() {
643 let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
644 let mut b = rec("src/foo.rs", "cyclomatic", 99.0, 15.0);
645 a.start_line = 10;
646 b.start_line = 20;
647 let va = render_value(&[a]);
648 let vb = render_value(&[b]);
649 assert_eq!(va[0]["fingerprint"], vb[0]["fingerprint"]);
650 }
651
652 #[test]
653 fn fingerprint_changes_with_metric() {
654 let a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
655 let b = rec("src/foo.rs", "cognitive", 17.0, 15.0);
656 let va = render_value(&[a]);
657 let vb = render_value(&[b]);
658 assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
659 }
660
661 #[test]
662 fn fingerprint_changes_with_function() {
663 let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
664 let mut b = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
665 a.function = Some("foo".into());
666 b.function = Some("bar".into());
667 let va = render_value(&[a]);
668 let vb = render_value(&[b]);
669 assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
670 }
671
672 #[test]
673 fn fingerprint_changes_with_path() {
674 let a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
675 let b = rec("src/bar.rs", "cyclomatic", 17.0, 15.0);
676 let va = render_value(&[a]);
677 let vb = render_value(&[b]);
678 assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
679 }
680
681 #[test]
682 fn fingerprint_handles_none_function() {
683 let none_fp = fingerprint("a.rs", None, "cyclomatic", 0);
684 let empty_fp = fingerprint("a.rs", Some(""), "cyclomatic", 0);
685 assert_eq!(none_fp, empty_fp);
686 }
687
688 #[test]
689 fn same_named_offenders_get_distinct_fingerprints() {
690 let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
696 let mut b = rec("src/foo.rs", "cyclomatic", 22.0, 15.0);
697 a.function = Some("new".into());
698 b.function = Some("new".into());
699 a.start_line = 10;
700 b.start_line = 40;
701 let v = render_value(&[a, b]);
702 let arr = v.as_array().expect("array");
703 assert_eq!(arr.len(), 2, "both offenders must be emitted");
704 assert_ne!(
705 arr[0]["fingerprint"], arr[1]["fingerprint"],
706 "distinct same-named offenders must get distinct fingerprints"
707 );
708 }
709
710 #[test]
711 fn first_same_triple_offender_keeps_historical_fingerprint() {
712 let legacy = fingerprint("src/foo.rs", Some("f"), "cyclomatic", 0);
715 let r = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
716 let v = render_value(&[r]);
717 assert_eq!(v[0]["fingerprint"], legacy);
718 }
719
720 #[test]
721 fn fingerprint_ordinal_changes_hash() {
722 let zero = fingerprint("a.rs", Some("f"), "cyclomatic", 0);
723 let one = fingerprint("a.rs", Some("f"), "cyclomatic", 1);
724 assert_ne!(zero, one);
725 }
726
727 #[test]
728 fn hex_lower_bytes_pads_low_bytes_to_two_chars() {
729 assert_eq!(
737 hex_lower_bytes(&[0x00, 0x01, 0x0f, 0x10, 0xab, 0xff]),
738 "00010f10abff",
739 );
740 assert_eq!(hex_lower_bytes(&[]), "");
742 assert_eq!(hex_lower_bytes(&[0x00]), "00");
744 }
745
746 #[test]
747 fn fingerprint_uses_full_truncation_width() {
748 let fp = fingerprint("a.rs", Some("fn"), "cyclomatic", 0);
752 assert_eq!(fp.len(), FINGERPRINT_BYTE_LEN * 2);
753 assert!(
754 fp.chars()
755 .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
756 );
757 }
758
759 #[test]
760 fn fingerprint_is_deterministic() {
761 let a = fingerprint("src/x.rs", Some("f"), "cyclomatic", 0);
762 let b = fingerprint("src/x.rs", Some("f"), "cyclomatic", 0);
763 assert_eq!(a, b);
764 }
765
766 #[cfg(unix)]
767 #[test]
768 fn non_utf8_path_is_skipped() {
769 use std::ffi::OsString;
770 use std::os::unix::ffi::OsStringExt;
771 let bad = OffenderRecord {
772 path: PathBuf::from(OsString::from_vec(b"weird-\xff\xfe.rs".to_vec())),
773 function: Some("f".into()),
774 start_line: 1,
775 end_line: 1,
776 start_col: None,
777 metric: "cyclomatic".into(),
778 value: 17.0,
779 limit: 15.0,
780 severity: Severity::Warning,
781 };
782 let good = rec("src/ok.rs", "cyclomatic", 17.0, 15.0);
783 let v = render_value(&[bad, good]);
784 let arr = v.as_array().expect("array");
785 assert_eq!(arr.len(), 1, "bad-path record skipped");
786 assert_eq!(arr[0]["location"]["path"], "src/ok.rs");
787 }
788
789 #[test]
790 fn windows_backslash_path_is_normalized() {
791 assert_eq!(
792 normalize_path(r"src\foo\bar.rs"),
793 Some("src/foo/bar.rs".to_owned())
794 );
795 }
796
797 #[test]
798 fn dot_slash_prefix_is_stripped() {
799 assert_eq!(
800 normalize_path("./src/foo.rs"),
801 Some("src/foo.rs".to_owned())
802 );
803 assert_eq!(
805 normalize_path("././src/foo.rs"),
806 Some("./src/foo.rs".to_owned())
807 );
808 }
809
810 #[test]
811 fn path_normalising_to_empty_is_skipped() {
812 assert_eq!(normalize_path("./"), None);
813 assert_eq!(normalize_path(""), None);
814 }
815
816 #[test]
817 fn offender_whose_path_normalises_to_empty_is_dropped_from_the_report() {
818 let v = render_value(&[
825 rec("./", "cyclomatic", 17.0, 15.0),
826 rec("src/good.rs", "cognitive", 20.0, 15.0),
827 ]);
828 let findings = v.as_array().expect("an array of findings");
829 assert_eq!(findings.len(), 1, "the empty-path offender is skipped: {v}");
830 assert_eq!(findings[0]["location"]["path"], "src/good.rs");
831 }
832
833 #[test]
834 fn start_line_zero_is_clamped_to_one() {
835 let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
836 r.start_line = 0;
837 r.end_line = 0;
838 let v = render_value(&[r]);
839 assert_eq!(v[0]["location"]["lines"]["begin"], 1);
840 assert!(v[0]["location"]["lines"].get("end").is_none());
841 }
842
843 #[test]
844 fn end_line_less_than_or_equal_start_omits_end() {
845 let mut equal = rec("a.rs", "cyclomatic", 17.0, 15.0);
846 equal.start_line = 10;
847 equal.end_line = 10;
848 let v_equal = render_value(&[equal]);
849 assert!(v_equal[0]["location"]["lines"].get("end").is_none());
850
851 let mut less = rec("a.rs", "cyclomatic", 17.0, 15.0);
852 less.start_line = 10;
853 less.end_line = 5;
854 let v_less = render_value(&[less]);
855 assert!(v_less[0]["location"]["lines"].get("end").is_none());
856 }
857
858 #[test]
859 fn start_col_zero_omits_positions() {
860 let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
861 r.start_col = Some(0);
862 let v = render_value(&[r]);
863 assert!(v[0]["location"].get("positions").is_none());
864 }
865
866 #[test]
867 fn description_includes_long_form_when_metric_known() {
868 let known = rec("a.rs", "cyclomatic", 17.0, 15.0);
869 let v = render_value(&[known]);
870 let desc = v[0]["description"].as_str().expect("string");
871 assert!(
872 desc.starts_with("Cyclomatic Complexity exceeds the configured threshold."),
873 "expected long-form prefix, got: {desc}"
874 );
875 assert!(
876 desc.ends_with("cyclomatic 17 exceeds limit 15"),
877 "expected default_message tail, got: {desc}"
878 );
879
880 let unknown = rec("a.rs", "made.up.metric", 1.0, 0.0);
881 let v = render_value(&[unknown]);
882 assert_eq!(v[0]["description"], "made.up.metric 1 exceeds limit 0");
883 }
884
885 #[test]
886 fn check_name_is_tool_namespaced() {
887 let r = rec("a.rs", "halstead.effort", 5000.0, 1000.0);
888 let v = render_value(&[r]);
889 assert_eq!(v[0]["check_name"], "big-code-analysis/halstead.effort");
890 }
891
892 #[test]
893 fn output_has_no_bom() {
894 let r = rec("a.rs", "cyclomatic", 17.0, 15.0);
895 let mut buf = Vec::new();
896 write_code_climate(&[r], &mut buf).expect("writing to Vec is infallible");
897 assert!(
902 !buf.starts_with(&[0xEF, 0xBB, 0xBF]),
903 "code-climate output must not start with a UTF-8 BOM"
904 );
905 assert_eq!(buf[0], b'[', "first byte must be the opening bracket");
906 }
907}