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