use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::thresholds::Violation;
pub(crate) const BASELINE_VERSION: u32 = 2;
const HEADER: &str = "\
# bca baseline file. Generated by `bca check --write-baseline`.
# Listed offenders are filtered from threshold checks; a function that
# gets worse than its recorded value still fails. Refresh with
# `--write-baseline` when entries become stale.
";
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
struct Key {
path: String,
function: String,
start_line: usize,
metric: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct BaselineEntry {
path: String,
function: String,
start_line: usize,
metric: String,
value: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct BaselineFile {
version: Option<u32>,
#[serde(default, rename = "entry")]
pub(crate) entries: Vec<BaselineEntry>,
}
#[derive(Debug)]
pub(crate) struct Baseline {
by_key: HashMap<Key, f64>,
}
impl Baseline {
pub(crate) fn from_str(text: &str) -> Result<Self, String> {
let file: BaselineFile =
toml::from_str(text).map_err(|e| format!("malformed baseline TOML: {e}"))?;
let version = file
.version
.ok_or_else(|| "baseline missing version field".to_string())?;
if version != BASELINE_VERSION {
return Err(format!(
"baseline version {version} is not supported by this bca \
(expected {BASELINE_VERSION}); regenerate with \
`bca check --write-baseline` or upgrade bca"
));
}
let mut by_key = HashMap::with_capacity(file.entries.len());
for e in file.entries {
if !e.value.is_finite() {
continue;
}
by_key.insert(
Key {
path: e.path,
function: e.function,
start_line: e.start_line,
metric: e.metric,
},
e.value,
);
}
Ok(Self { by_key })
}
pub(crate) fn covers(&self, v: &Violation) -> bool {
let key = Key {
path: normalize_path(&v.path),
function: v.function.clone(),
start_line: v.start_line,
metric: v.metric.to_string(),
};
self.by_key
.get(&key)
.is_some_and(|&baseline| v.value <= baseline)
}
}
pub(crate) fn from_violations(violations: Vec<Violation>) -> BaselineFile {
let mut entries: Vec<BaselineEntry> = violations
.into_iter()
.filter_map(|v| {
if !v.value.is_finite() {
eprintln!(
"warning: skipping non-finite value for {}:{}-{}: {} = {}",
v.path.display(),
v.start_line,
v.end_line,
v.metric,
v.value,
);
return None;
}
Some(BaselineEntry {
path: normalize_path(&v.path),
function: v.function,
start_line: v.start_line,
metric: v.metric.to_string(),
value: v.value,
})
})
.collect();
entries.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.start_line.cmp(&b.start_line))
.then(a.function.cmp(&b.function))
.then(a.metric.cmp(&b.metric))
});
BaselineFile {
version: Some(BASELINE_VERSION),
entries,
}
}
pub(crate) fn render(file: &BaselineFile) -> Result<String, toml::ser::Error> {
let body = toml::to_string(file)?;
Ok(format!("{HEADER}{body}"))
}
fn normalize_path(p: &Path) -> String {
match p.to_str() {
Some(s) => {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
let b = if b == b'\\' { b'/' } else { b };
push_percent_encoded_byte(&mut out, b);
}
out
}
None => encode_non_utf8_path(p),
}
}
#[cfg(unix)]
fn encode_non_utf8_path(p: &Path) -> String {
use std::os::unix::ffi::OsStrExt;
percent_encode_path_bytes(p.as_os_str().as_bytes())
}
#[cfg(windows)]
fn encode_non_utf8_path(p: &Path) -> String {
use std::os::windows::ffi::OsStrExt;
percent_encode_wtf16(p.as_os_str().encode_wide())
}
#[cfg(not(any(unix, windows)))]
fn encode_non_utf8_path(p: &Path) -> String {
let mut out = String::from("\u{FFFD}");
for &b in p.to_string_lossy().as_bytes() {
push_percent_encoded_byte(&mut out, b);
}
out
}
#[cfg(unix)]
fn percent_encode_path_bytes(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len());
for &b in bytes {
push_percent_encoded_byte(&mut out, b);
}
out
}
fn push_percent_encoded_byte(out: &mut String, b: u8) {
use std::fmt::Write;
let is_unreserved = b.is_ascii_alphanumeric()
|| matches!(
b,
b'-' | b'_' | b'.' | b'~' | b'/' | b':' | b'+' | b',' | b' '
);
if is_unreserved {
out.push(b as char);
} else {
let _ = write!(out, "%{b:02X}");
}
}
#[cfg(any(windows, test))]
pub(crate) fn percent_encode_wtf16(units: impl IntoIterator<Item = u16>) -> String {
use std::fmt::Write;
let mut out = String::new();
let mut buf = [0u8; 4];
for r in char::decode_utf16(units) {
match r {
Ok(c) => {
for &b in c.encode_utf8(&mut buf).as_bytes() {
push_percent_encoded_byte(&mut out, b);
}
}
Err(e) => {
let _ = write!(out, "%u{:04X}", e.unpaired_surrogate());
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn v(
path: &str,
function: &str,
start_line: usize,
metric: &'static str,
value: f64,
) -> Violation {
Violation {
path: PathBuf::from(path),
start_line,
end_line: start_line + 1,
function: function.to_string(),
metric,
value,
limit: 1.0,
}
}
fn parse(text: &str) -> Result<Baseline, String> {
Baseline::from_str(text)
}
#[test]
fn parse_minimal_version_only() {
let b = parse("version = 2\n").expect("minimal parse");
assert_eq!(b.by_key.len(), 0);
}
#[test]
fn parse_round_trip_preserves_entries() {
let original = from_violations(vec![
v("src/a.rs", "foo", 10, "cyclomatic", 5.0),
v("src/b.rs", "bar", 20, "cognitive", 7.0),
]);
let rendered = render(&original).expect("render");
let reloaded = parse(&rendered).expect("reload");
assert_eq!(reloaded.by_key.len(), 2);
let v_now = v("src/a.rs", "foo", 10, "cyclomatic", 5.0);
assert!(reloaded.covers(&v_now));
}
#[test]
fn parse_rejects_higher_version() {
let err = parse("version = 99\n").unwrap_err();
assert!(
err.contains("upgrade bca") || err.contains("regenerate"),
"msg: {err}"
);
}
#[test]
fn parse_rejects_missing_version() {
let err = parse("[[entry]]\npath=\"a\"\nfunction=\"f\"\nstart_line=1\nmetric=\"cyclomatic\"\nvalue=1.0\n").unwrap_err();
assert!(err.contains("missing version field"), "msg: {err}");
}
#[test]
fn parse_rejects_empty_file() {
let err = parse("").unwrap_err();
assert!(err.contains("missing version field"), "msg: {err}");
}
#[test]
fn parse_rejects_malformed_value() {
let err = parse(
"version = 2\n[[entry]]\npath=\"a\"\nfunction=\"f\"\nstart_line=1\nmetric=\"cyclomatic\"\nvalue=\"oops\"\n",
)
.unwrap_err();
assert!(err.contains("malformed baseline TOML"), "msg: {err}");
}
#[test]
fn parse_silently_ignores_unknown_metric() {
let b = parse(
"version = 2\n[[entry]]\npath=\"a\"\nfunction=\"f\"\nstart_line=1\nmetric=\"imaginary\"\nvalue=1.0\n",
)
.expect("parse");
assert_eq!(b.by_key.len(), 1);
let v_real = v("a", "f", 1, "cyclomatic", 1.0);
assert!(!b.covers(&v_real));
}
#[test]
fn parse_silently_ignores_unknown_fields() {
let b = parse(
"version = 2\n[[entry]]\npath=\"a\"\nfunction=\"f\"\nstart_line=1\nmetric=\"cyclomatic\"\nvalue=1.0\nextra_field=42\n",
)
.expect("parse");
assert_eq!(b.by_key.len(), 1);
}
#[test]
fn from_violations_skips_non_finite() {
let file = from_violations(vec![
v("a", "f", 1, "cyclomatic", f64::NAN),
v("a", "g", 2, "cyclomatic", f64::INFINITY),
v("a", "h", 3, "cyclomatic", f64::NEG_INFINITY),
v("a", "i", 4, "cyclomatic", 5.0),
]);
assert_eq!(file.entries.len(), 1);
assert_eq!(file.entries[0].function, "i");
}
#[test]
fn from_violations_deterministic_order() {
let unsorted = vec![
v("src/z.rs", "z", 100, "cyclomatic", 5.0),
v("src/a.rs", "b", 10, "cognitive", 4.0),
v("src/a.rs", "a", 10, "cognitive", 3.0),
v("src/a.rs", "a", 10, "cyclomatic", 5.0),
v("src/a.rs", "a", 99, "cyclomatic", 6.0),
];
let file = from_violations(unsorted);
assert_eq!(file.entries[0].path, "src/a.rs");
assert_eq!(file.entries[0].start_line, 10);
assert_eq!(file.entries[0].function, "a");
assert_eq!(file.entries[0].metric, "cognitive");
assert_eq!(file.entries[1].path, "src/a.rs");
assert_eq!(file.entries[1].start_line, 10);
assert_eq!(file.entries[1].function, "a");
assert_eq!(file.entries[1].metric, "cyclomatic");
assert_eq!(file.entries[2].path, "src/a.rs");
assert_eq!(file.entries[2].start_line, 10);
assert_eq!(file.entries[2].function, "b");
assert_eq!(file.entries[3].path, "src/a.rs");
assert_eq!(file.entries[3].start_line, 99);
assert_eq!(file.entries[4].path, "src/z.rs");
}
#[test]
fn from_violations_byte_equal_across_two_calls() {
let input = vec![
v("src/a.rs", "foo", 10, "cyclomatic", 5.0),
v("src/b.rs", "bar", 20, "cognitive", 7.0),
];
let a = render(&from_violations(input.clone())).expect("render a");
let b = render(&from_violations(input)).expect("render b");
assert_eq!(a, b);
}
#[test]
fn path_normalized_forward_slash_on_serialize() {
let file = from_violations(vec![v("a\\b\\c.rs", "f", 1, "cyclomatic", 5.0)]);
assert_eq!(file.entries[0].path, "a/b/c.rs");
}
fn baseline_with(entries: Vec<BaselineEntry>) -> Baseline {
let file = BaselineFile {
version: Some(BASELINE_VERSION),
entries,
};
let text = render(&file).expect("render");
Baseline::from_str(&text).expect("parse")
}
fn entry(
path: &str,
function: &str,
start_line: usize,
metric: &str,
value: f64,
) -> BaselineEntry {
BaselineEntry {
path: path.to_string(),
function: function.to_string(),
start_line,
metric: metric.to_string(),
value,
}
}
#[test]
fn covers_at_exact_baseline() {
let b = baseline_with(vec![entry("a", "f", 1, "cyclomatic", 5.0)]);
assert!(b.covers(&v("a", "f", 1, "cyclomatic", 5.0)));
}
#[test]
fn covers_below_baseline() {
let b = baseline_with(vec![entry("a", "f", 1, "cyclomatic", 5.0)]);
assert!(b.covers(&v("a", "f", 1, "cyclomatic", 3.0)));
}
#[test]
fn covers_rejects_worsened() {
let b = baseline_with(vec![entry("a", "f", 1, "cyclomatic", 5.0)]);
assert!(!b.covers(&v("a", "f", 1, "cyclomatic", 6.0)));
}
#[test]
fn covers_rejects_different_path() {
let b = baseline_with(vec![entry("a", "f", 1, "cyclomatic", 5.0)]);
assert!(!b.covers(&v("b", "f", 1, "cyclomatic", 5.0)));
}
#[test]
fn covers_rejects_different_function() {
let b = baseline_with(vec![entry("a", "f", 1, "cyclomatic", 5.0)]);
assert!(!b.covers(&v("a", "g", 1, "cyclomatic", 5.0)));
}
#[test]
fn covers_rejects_different_start_line() {
let b = baseline_with(vec![entry("a", "f", 1, "cyclomatic", 5.0)]);
assert!(!b.covers(&v("a", "f", 2, "cyclomatic", 5.0)));
}
#[test]
fn covers_rejects_different_metric() {
let b = baseline_with(vec![entry("a", "f", 1, "cyclomatic", 5.0)]);
assert!(!b.covers(&v("a", "f", 1, "cognitive", 5.0)));
}
#[test]
fn covers_normalizes_filter_path() {
let b = baseline_with(vec![entry("src/a.rs", "f", 1, "cyclomatic", 5.0)]);
assert!(b.covers(&v("src\\a.rs", "f", 1, "cyclomatic", 5.0)));
}
#[test]
fn normalize_path_utf8_unchanged_for_unreserved_ascii() {
assert_eq!(normalize_path(Path::new("src/foo.rs")), "src/foo.rs");
assert_eq!(normalize_path(Path::new("crates/a/b.rs")), "crates/a/b.rs");
assert_eq!(normalize_path(Path::new("a\\b\\c.rs")), "a/b/c.rs");
}
#[test]
fn normalize_path_utf8_escapes_percent() {
assert_eq!(normalize_path(Path::new("foo%FF.rs")), "foo%25FF.rs");
assert_eq!(normalize_path(Path::new("a%b%c.rs")), "a%25b%25c.rs");
}
#[cfg(unix)]
#[test]
fn normalize_path_utf8_percent_vs_non_utf8_byte_no_collision() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let utf8 = Path::new("foo%FF.rs");
let non_utf8 = PathBuf::from(OsStr::from_bytes(b"foo\xff.rs"));
let key_utf8 = normalize_path(utf8);
let key_non_utf8 = normalize_path(&non_utf8);
assert_eq!(key_utf8, "foo%25FF.rs");
assert_eq!(key_non_utf8, "foo%FF.rs");
assert_ne!(key_utf8, key_non_utf8);
}
#[cfg(unix)]
#[test]
fn baseline_key_preserves_non_utf8_identity() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let a = PathBuf::from("src").join(OsStr::from_bytes(b"bad-\xff\xfe.rs"));
let b = PathBuf::from("src").join(OsStr::from_bytes(b"bad-\xfe\xff.rs"));
let key_a = normalize_path(&a);
let key_b = normalize_path(&b);
assert_ne!(key_a, key_b);
assert!(key_a.is_ascii());
assert!(key_b.is_ascii());
}
#[test]
fn wtf16_encode_pure_ascii() {
let out = percent_encode_wtf16("src/foo.rs".encode_utf16());
assert_eq!(out, "src/foo.rs");
}
#[test]
fn wtf16_encode_empty() {
assert_eq!(percent_encode_wtf16(std::iter::empty::<u16>()), "");
}
#[test]
fn wtf16_encode_bmp_non_ascii() {
let out = percent_encode_wtf16("é".encode_utf16());
assert_eq!(out, "%C3%A9");
}
#[test]
fn wtf16_encode_supplementary_plane() {
let units = [0xD83D_u16, 0xDE00_u16];
let out = percent_encode_wtf16(units);
assert_eq!(out, "%F0%9F%98%80");
assert_eq!(out, percent_encode_wtf16("😀".encode_utf16()));
}
#[test]
fn wtf16_encode_unpaired_high_surrogate() {
let out = percent_encode_wtf16([0xD83D_u16]);
assert_eq!(out, "%uD83D");
}
#[test]
fn wtf16_encode_unpaired_low_surrogate() {
let out = percent_encode_wtf16([0xDE00_u16]);
assert_eq!(out, "%uDE00");
}
#[test]
fn wtf16_encode_high_followed_by_non_low_is_unpaired() {
let units = [0xD83D_u16, u16::from(b'x')];
let out = percent_encode_wtf16(units);
assert_eq!(out, "%uD83Dx");
}
#[test]
fn wtf16_encode_leading_low_then_pair() {
let units = [0xDC00_u16, 0xD83D_u16, 0xDE00_u16];
let out = percent_encode_wtf16(units);
assert_eq!(out, "%uDC00%F0%9F%98%80");
}
#[test]
fn wtf16_encode_distinct_unpaired_surrogates_do_not_collide() {
let a = percent_encode_wtf16([0xD83D_u16]);
let b = percent_encode_wtf16([0xDE00_u16]);
assert_ne!(a, b);
let c = percent_encode_wtf16([0xD800_u16]);
let d = percent_encode_wtf16([0xDBFF_u16]);
assert_ne!(c, d);
}
#[test]
fn wtf16_encode_marker_never_emitted_by_scalar_bytes() {
for codepoint in ['u', '%', '!', '\u{00E9}', '\u{1F600}'] {
let s = codepoint.to_string();
let out = percent_encode_wtf16(s.encode_utf16());
assert!(!out.contains("%u"), "scalar {codepoint:?} produced {out:?}");
}
}
#[cfg(windows)]
#[test]
fn baseline_key_preserves_non_utf16_identity_on_windows() {
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
let a_units: [u16; 5] = [
u16::from(b'a'),
u16::from(b'/'),
0xD83D,
u16::from(b'.'),
u16::from(b's'),
];
let b_units: [u16; 5] = [
u16::from(b'a'),
u16::from(b'/'),
0xDE00,
u16::from(b'.'),
u16::from(b's'),
];
let path_a = PathBuf::from(OsString::from_wide(&a_units));
let path_b = PathBuf::from(OsString::from_wide(&b_units));
let key_a = normalize_path(&path_a);
let key_b = normalize_path(&path_b);
assert_ne!(key_a, key_b);
assert!(key_a.is_ascii());
assert!(key_b.is_ascii());
}
#[cfg(unix)]
#[test]
fn baseline_covers_distinguishes_non_utf8_paths() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let path_a = PathBuf::from("src").join(OsStr::from_bytes(b"\xff\xfe.rs"));
let path_b = PathBuf::from("src").join(OsStr::from_bytes(b"\xfe\xff.rs"));
let violation_a = Violation {
path: path_a.clone(),
start_line: 1,
end_line: 2,
function: "f".to_string(),
metric: "cyclomatic",
value: 5.0,
limit: 1.0,
};
let violation_b = Violation {
path: path_b,
start_line: 1,
end_line: 2,
function: "f".to_string(),
metric: "cyclomatic",
value: 5.0,
limit: 1.0,
};
let file = from_violations(vec![violation_a.clone()]);
let rendered = render(&file).expect("render");
let b = Baseline::from_str(&rendered).expect("parse");
assert!(b.covers(&violation_a));
assert!(!b.covers(&violation_b));
}
}