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::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 {
178 let floor = match severity {
186 Severity::Warning => "minor",
187 Severity::Error => "major",
188 };
189 if !value.is_finite() || !limit.is_finite() || limit <= 0.0 || value <= 0.0 {
194 return floor;
195 }
196 let lower_is_worse = crate::metric_catalog::lower_is_worse(metric);
197 let ratio = if lower_is_worse {
198 limit / value
199 } else {
200 value / limit
201 };
202 let band = if ratio <= 1.5 {
203 "minor"
204 } else if ratio <= 2.0 {
205 "major"
206 } else if ratio <= 4.0 {
207 "critical"
208 } else {
209 "blocker"
210 };
211 worst_severity(band, floor)
212}
213
214fn severity_rank(level: &str) -> u8 {
220 match level {
221 "minor" => 1,
222 "major" => 2,
223 "critical" => 3,
224 "blocker" => 4,
225 _ => 0,
226 }
227}
228
229fn worst_severity(a: &'static str, b: &'static str) -> &'static str {
231 if severity_rank(a) >= severity_rank(b) {
232 a
233 } else {
234 b
235 }
236}
237
238fn fingerprint(path: &str, function: Option<&str>, metric: &str, ordinal: u32) -> String {
261 let mut h = Sha256::new();
262 h.update(path.as_bytes());
263 h.update(b"\0");
264 h.update(function.unwrap_or("").as_bytes());
265 h.update(b"\0");
266 h.update(metric.as_bytes());
267 if ordinal > 0 {
268 h.update(b"\0");
269 h.update(ordinal.to_le_bytes());
270 }
271 let digest = h.finalize();
272 hex_lower_bytes(&digest[..FINGERPRINT_BYTE_LEN])
273}
274
275fn hex_lower_bytes(bytes: &[u8]) -> String {
280 let mut out = String::with_capacity(bytes.len() * 2);
281 for byte in bytes {
282 let _ = write!(&mut out, "{byte:02x}");
286 }
287 out
288}
289
290fn normalize_path(raw: &str) -> Option<String> {
291 let normalized: Cow<'_, str> = if raw.contains('\\') {
295 Cow::Owned(raw.replace('\\', "/"))
296 } else {
297 Cow::Borrowed(raw)
298 };
299 let stripped = normalized.strip_prefix("./").unwrap_or(&normalized);
300 if stripped.is_empty() {
301 None
302 } else {
303 Some(stripped.to_owned())
304 }
305}
306
307fn build_description(record: &OffenderRecord) -> String {
308 let Some(long_form) = lookup(&record.metric).map(|i| i.long_description) else {
309 return record.default_message();
310 };
311 let tail = record.default_message();
317 let mut out = String::with_capacity(long_form.len() + 1 + tail.len());
318 out.push_str(long_form);
319 out.push(' ');
320 out.push_str(&tail);
321 out
322}
323
324#[cfg(test)]
325#[allow(
326 clippy::float_cmp,
327 clippy::cast_precision_loss,
328 clippy::cast_possible_truncation,
329 clippy::cast_sign_loss,
330 clippy::similar_names,
331 clippy::doc_markdown,
332 clippy::needless_raw_string_hashes,
333 clippy::too_many_lines
334)]
335mod tests {
336 use super::*;
337 use std::path::PathBuf;
338
339 fn rec(path: &str, metric: &str, value: f64, limit: f64) -> OffenderRecord {
340 OffenderRecord {
341 path: PathBuf::from(path),
342 function: Some("f".into()),
343 start_line: 42,
344 end_line: 50,
345 start_col: Some(5),
346 metric: metric.into(),
347 value,
348 limit,
349 severity: Severity::Warning,
350 }
351 }
352
353 fn render(offenders: &[OffenderRecord]) -> String {
354 let mut buf = Vec::new();
355 write_code_climate(offenders, &mut buf).expect("writing to Vec is infallible");
356 String::from_utf8(buf).expect("output is UTF-8")
357 }
358
359 fn render_value(offenders: &[OffenderRecord]) -> serde_json::Value {
360 serde_json::from_str(&render(offenders)).expect("valid JSON")
361 }
362
363 #[test]
364 fn empty_input_emits_bracket_newline() {
365 assert_eq!(render(&[]), "[]\n");
366 }
367
368 #[test]
369 fn single_offender_anchored_snapshot() {
370 let mut r = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
371 r.start_col = None;
372 let v = render_value(&[r]);
373 insta::assert_json_snapshot!(v, @r#"
374 [
375 {
376 "check_name": "big-code-analysis/cyclomatic",
377 "description": "Cyclomatic Complexity exceeds the configured threshold. cyclomatic 17 exceeds limit 15",
378 "fingerprint": "209c41c7caa70e296f0bb82946cce7cc",
379 "location": {
380 "lines": {
381 "begin": 42,
382 "end": 50
383 },
384 "path": "src/foo.rs"
385 },
386 "severity": "minor"
387 }
388 ]
389 "#);
390 }
391
392 #[test]
393 fn multi_offender_with_column_and_file_level() {
394 let with_col = rec("src/a.rs", "cyclomatic", 30.0, 15.0);
395 let mut file_level = rec("src/b.rs", "loc.lloc", 250.0, 100.0);
396 file_level.function = None;
397 file_level.start_col = None;
398 let v = render_value(&[with_col, file_level]);
399 insta::assert_json_snapshot!(v, @r#"
400 [
401 {
402 "check_name": "big-code-analysis/cyclomatic",
403 "description": "Cyclomatic Complexity exceeds the configured threshold. cyclomatic 30 exceeds limit 15",
404 "fingerprint": "03dd26a883d163bd752853e1dd15557d",
405 "location": {
406 "lines": {
407 "begin": 42,
408 "end": 50
409 },
410 "path": "src/a.rs",
411 "positions": {
412 "begin": {
413 "column": 5,
414 "line": 42
415 }
416 }
417 },
418 "severity": "major"
419 },
420 {
421 "check_name": "big-code-analysis/loc.lloc",
422 "description": "Logical lines of code exceed the configured threshold. loc.lloc 250 exceeds limit 100",
423 "fingerprint": "cc3f570c9b909e186681cf36a6cffe5c",
424 "location": {
425 "lines": {
426 "begin": 42,
427 "end": 50
428 },
429 "path": "src/b.rs"
430 },
431 "severity": "critical"
432 }
433 ]
434 "#);
435 }
436
437 #[test]
438 fn severity_band_table_upward_metric() {
439 assert_eq!(
441 severity_band("cyclomatic", 10.0, 10.0, Severity::Warning),
442 "minor"
443 );
444 assert_eq!(
445 severity_band("cyclomatic", 12.5, 10.0, Severity::Warning),
446 "minor"
447 );
448 assert_eq!(
449 severity_band("cyclomatic", 17.5, 10.0, Severity::Warning),
450 "major"
451 );
452 assert_eq!(
453 severity_band("cyclomatic", 30.0, 10.0, Severity::Warning),
454 "critical"
455 );
456 assert_eq!(
457 severity_band("cyclomatic", 100.0, 10.0, Severity::Warning),
458 "blocker"
459 );
460 }
461
462 #[test]
463 fn severity_band_table_mi_family_inverts() {
464 assert_eq!(
471 severity_band("mi.original", 100.0, 100.0, Severity::Warning),
472 "minor"
473 );
474 assert_eq!(
476 severity_band("mi.original", 50.0, 100.0, Severity::Warning),
477 "major"
478 );
479 assert_eq!(
481 severity_band("mi.original", 40.0, 100.0, Severity::Warning),
482 "critical"
483 );
484 assert_eq!(
486 severity_band("mi.original", 10.0, 100.0, Severity::Warning),
487 "blocker"
488 );
489 }
490
491 #[test]
492 fn declared_error_severity_is_a_floor_not_overridden_by_band() {
493 assert_eq!(
498 severity_band("cyclomatic", 11.0, 10.0, Severity::Error),
499 "major",
500 "Error must floor at major even at a sub-1.5x ratio"
501 );
502 assert_eq!(
504 severity_band("cyclomatic", 30.0, 10.0, Severity::Error),
505 "critical"
506 );
507 assert_eq!(
509 severity_band("cyclomatic", 11.0, 10.0, Severity::Warning),
510 "minor"
511 );
512 }
513
514 #[test]
515 fn description_lower_is_worse_uses_falls_below() {
516 let mut r = rec("a.rs", "mi.original", 30.0, 50.0);
520 r.start_col = None;
521 let v = render_value(&[r]);
522 let desc = v[0]["description"].as_str().expect("string");
523 assert!(
524 desc.contains("falls below limit 50"),
525 "mi.* description must say 'falls below limit', got: {desc}"
526 );
527 assert!(
528 desc.starts_with("Maintainability Index falls below the configured threshold."),
529 "mi.* long-form prefix expected, got: {desc}"
530 );
531 }
532
533 #[test]
534 fn severity_band_falls_back_when_limit_zero() {
535 assert_eq!(
536 severity_band("cyclomatic", 5.0, 0.0, Severity::Warning),
537 "minor"
538 );
539 assert_eq!(
540 severity_band("cyclomatic", 5.0, 0.0, Severity::Error),
541 "major"
542 );
543 }
544
545 #[test]
546 fn severity_band_falls_back_when_value_nan() {
547 assert_eq!(
548 severity_band("cyclomatic", f64::NAN, 10.0, Severity::Warning),
549 "minor"
550 );
551 assert_eq!(
552 severity_band("cyclomatic", f64::NAN, 10.0, Severity::Error),
553 "major"
554 );
555 }
556
557 #[test]
558 fn severity_band_falls_back_when_value_inf() {
559 assert_eq!(
560 severity_band("cyclomatic", f64::INFINITY, 10.0, Severity::Warning),
561 "minor"
562 );
563 assert_eq!(
564 severity_band("cyclomatic", f64::INFINITY, 10.0, Severity::Error),
565 "major"
566 );
567 }
568
569 #[test]
570 fn fingerprint_is_line_value_insensitive() {
571 let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
572 let mut b = rec("src/foo.rs", "cyclomatic", 99.0, 15.0);
573 a.start_line = 10;
574 b.start_line = 20;
575 let va = render_value(&[a]);
576 let vb = render_value(&[b]);
577 assert_eq!(va[0]["fingerprint"], vb[0]["fingerprint"]);
578 }
579
580 #[test]
581 fn fingerprint_changes_with_metric() {
582 let a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
583 let b = rec("src/foo.rs", "cognitive", 17.0, 15.0);
584 let va = render_value(&[a]);
585 let vb = render_value(&[b]);
586 assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
587 }
588
589 #[test]
590 fn fingerprint_changes_with_function() {
591 let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
592 let mut b = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
593 a.function = Some("foo".into());
594 b.function = Some("bar".into());
595 let va = render_value(&[a]);
596 let vb = render_value(&[b]);
597 assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
598 }
599
600 #[test]
601 fn fingerprint_changes_with_path() {
602 let a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
603 let b = rec("src/bar.rs", "cyclomatic", 17.0, 15.0);
604 let va = render_value(&[a]);
605 let vb = render_value(&[b]);
606 assert_ne!(va[0]["fingerprint"], vb[0]["fingerprint"]);
607 }
608
609 #[test]
610 fn fingerprint_handles_none_function() {
611 let none_fp = fingerprint("a.rs", None, "cyclomatic", 0);
612 let empty_fp = fingerprint("a.rs", Some(""), "cyclomatic", 0);
613 assert_eq!(none_fp, empty_fp);
614 }
615
616 #[test]
617 fn same_named_offenders_get_distinct_fingerprints() {
618 let mut a = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
624 let mut b = rec("src/foo.rs", "cyclomatic", 22.0, 15.0);
625 a.function = Some("new".into());
626 b.function = Some("new".into());
627 a.start_line = 10;
628 b.start_line = 40;
629 let v = render_value(&[a, b]);
630 let arr = v.as_array().expect("array");
631 assert_eq!(arr.len(), 2, "both offenders must be emitted");
632 assert_ne!(
633 arr[0]["fingerprint"], arr[1]["fingerprint"],
634 "distinct same-named offenders must get distinct fingerprints"
635 );
636 }
637
638 #[test]
639 fn first_same_triple_offender_keeps_historical_fingerprint() {
640 let legacy = fingerprint("src/foo.rs", Some("f"), "cyclomatic", 0);
643 let r = rec("src/foo.rs", "cyclomatic", 17.0, 15.0);
644 let v = render_value(&[r]);
645 assert_eq!(v[0]["fingerprint"], legacy);
646 }
647
648 #[test]
649 fn fingerprint_ordinal_changes_hash() {
650 let zero = fingerprint("a.rs", Some("f"), "cyclomatic", 0);
651 let one = fingerprint("a.rs", Some("f"), "cyclomatic", 1);
652 assert_ne!(zero, one);
653 }
654
655 #[test]
656 fn hex_lower_bytes_pads_low_bytes_to_two_chars() {
657 assert_eq!(
665 hex_lower_bytes(&[0x00, 0x01, 0x0f, 0x10, 0xab, 0xff]),
666 "00010f10abff",
667 );
668 assert_eq!(hex_lower_bytes(&[]), "");
670 assert_eq!(hex_lower_bytes(&[0x00]), "00");
672 }
673
674 #[test]
675 fn fingerprint_uses_full_truncation_width() {
676 let fp = fingerprint("a.rs", Some("fn"), "cyclomatic", 0);
680 assert_eq!(fp.len(), FINGERPRINT_BYTE_LEN * 2);
681 assert!(
682 fp.chars()
683 .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
684 );
685 }
686
687 #[test]
688 fn fingerprint_is_deterministic() {
689 let a = fingerprint("src/x.rs", Some("f"), "cyclomatic", 0);
690 let b = fingerprint("src/x.rs", Some("f"), "cyclomatic", 0);
691 assert_eq!(a, b);
692 }
693
694 #[cfg(unix)]
695 #[test]
696 fn non_utf8_path_is_skipped() {
697 use std::ffi::OsString;
698 use std::os::unix::ffi::OsStringExt;
699 let bad = OffenderRecord {
700 path: PathBuf::from(OsString::from_vec(b"weird-\xff\xfe.rs".to_vec())),
701 function: Some("f".into()),
702 start_line: 1,
703 end_line: 1,
704 start_col: None,
705 metric: "cyclomatic".into(),
706 value: 17.0,
707 limit: 15.0,
708 severity: Severity::Warning,
709 };
710 let good = rec("src/ok.rs", "cyclomatic", 17.0, 15.0);
711 let v = render_value(&[bad, good]);
712 let arr = v.as_array().expect("array");
713 assert_eq!(arr.len(), 1, "bad-path record skipped");
714 assert_eq!(arr[0]["location"]["path"], "src/ok.rs");
715 }
716
717 #[test]
718 fn windows_backslash_path_is_normalized() {
719 assert_eq!(
720 normalize_path(r"src\foo\bar.rs"),
721 Some("src/foo/bar.rs".to_owned())
722 );
723 }
724
725 #[test]
726 fn dot_slash_prefix_is_stripped() {
727 assert_eq!(
728 normalize_path("./src/foo.rs"),
729 Some("src/foo.rs".to_owned())
730 );
731 assert_eq!(
733 normalize_path("././src/foo.rs"),
734 Some("./src/foo.rs".to_owned())
735 );
736 }
737
738 #[test]
739 fn path_normalising_to_empty_is_skipped() {
740 assert_eq!(normalize_path("./"), None);
741 assert_eq!(normalize_path(""), None);
742 }
743
744 #[test]
745 fn offender_whose_path_normalises_to_empty_is_dropped_from_the_report() {
746 let v = render_value(&[
753 rec("./", "cyclomatic", 17.0, 15.0),
754 rec("src/good.rs", "cognitive", 20.0, 15.0),
755 ]);
756 let findings = v.as_array().expect("an array of findings");
757 assert_eq!(findings.len(), 1, "the empty-path offender is skipped: {v}");
758 assert_eq!(findings[0]["location"]["path"], "src/good.rs");
759 }
760
761 #[test]
762 fn start_line_zero_is_clamped_to_one() {
763 let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
764 r.start_line = 0;
765 r.end_line = 0;
766 let v = render_value(&[r]);
767 assert_eq!(v[0]["location"]["lines"]["begin"], 1);
768 assert!(v[0]["location"]["lines"].get("end").is_none());
769 }
770
771 #[test]
772 fn end_line_less_than_or_equal_start_omits_end() {
773 let mut equal = rec("a.rs", "cyclomatic", 17.0, 15.0);
774 equal.start_line = 10;
775 equal.end_line = 10;
776 let v_equal = render_value(&[equal]);
777 assert!(v_equal[0]["location"]["lines"].get("end").is_none());
778
779 let mut less = rec("a.rs", "cyclomatic", 17.0, 15.0);
780 less.start_line = 10;
781 less.end_line = 5;
782 let v_less = render_value(&[less]);
783 assert!(v_less[0]["location"]["lines"].get("end").is_none());
784 }
785
786 #[test]
787 fn start_col_zero_omits_positions() {
788 let mut r = rec("a.rs", "cyclomatic", 17.0, 15.0);
789 r.start_col = Some(0);
790 let v = render_value(&[r]);
791 assert!(v[0]["location"].get("positions").is_none());
792 }
793
794 #[test]
795 fn description_includes_long_form_when_metric_known() {
796 let known = rec("a.rs", "cyclomatic", 17.0, 15.0);
797 let v = render_value(&[known]);
798 let desc = v[0]["description"].as_str().expect("string");
799 assert!(
800 desc.starts_with("Cyclomatic Complexity exceeds the configured threshold."),
801 "expected long-form prefix, got: {desc}"
802 );
803 assert!(
804 desc.ends_with("cyclomatic 17 exceeds limit 15"),
805 "expected default_message tail, got: {desc}"
806 );
807
808 let unknown = rec("a.rs", "made.up.metric", 1.0, 0.0);
809 let v = render_value(&[unknown]);
810 assert_eq!(v[0]["description"], "made.up.metric 1 exceeds limit 0");
811 }
812
813 #[test]
814 fn check_name_is_tool_namespaced() {
815 let r = rec("a.rs", "halstead.effort", 5000.0, 1000.0);
816 let v = render_value(&[r]);
817 assert_eq!(v[0]["check_name"], "big-code-analysis/halstead.effort");
818 }
819
820 #[test]
821 fn output_has_no_bom() {
822 let r = rec("a.rs", "cyclomatic", 17.0, 15.0);
823 let mut buf = Vec::new();
824 write_code_climate(&[r], &mut buf).expect("writing to Vec is infallible");
825 assert!(
830 !buf.starts_with(&[0xEF, 0xBB, 0xBF]),
831 "code-climate output must not start with a UTF-8 BOM"
832 );
833 assert_eq!(buf[0], b'[', "first byte must be the opening bracket");
834 }
835}