use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const MAX_CONTRACT_REGIONS: usize = 8;
pub const FULL_COVERAGE_PERMILLE: u16 = 1000;
const MAX_LABEL_LEN: usize = 80;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ContractError {
#[error("a contract needs at least one region")]
NoRegions,
#[error("{0} regions exceeds the {MAX_CONTRACT_REGIONS}-region cap — merge adjacent ranges")]
TooManyRegions(usize),
#[error("region {index}: lines {start}-{end} is not a 1-based inclusive range")]
BadRange {
index: usize,
start: u32,
end: u32,
},
#[error("min-permille {0} outside 1..=1000 (1000 = every region)")]
BadThreshold(u16),
#[error("region {0}: label exceeds {MAX_LABEL_LEN} characters")]
LabelTooLong(usize),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "RegionWire")]
pub struct Region {
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
start: u32,
end: u32,
}
#[derive(Deserialize)]
struct RegionWire {
#[serde(default)]
label: Option<String>,
start: u32,
end: u32,
}
impl TryFrom<RegionWire> for Region {
type Error = ContractError;
fn try_from(w: RegionWire) -> Result<Self, ContractError> {
Region::new(w.label, w.start, w.end, 0)
}
}
impl Region {
pub fn new(
label: Option<String>,
start: u32,
end: u32,
index: usize,
) -> Result<Self, ContractError> {
if start == 0 || start > end {
return Err(ContractError::BadRange { index, start, end });
}
if label.as_deref().is_some_and(|l| l.len() > MAX_LABEL_LEN) {
return Err(ContractError::LabelTooLong(index));
}
Ok(Self { label, start, end })
}
#[must_use]
pub fn label(&self) -> Option<&str> {
self.label.as_deref()
}
#[must_use]
pub fn start(&self) -> u32 {
self.start
}
#[must_use]
pub fn end(&self) -> u32 {
self.end
}
#[must_use]
pub fn overlaps(&self, from: u32, to: u32) -> bool {
from <= self.end && to >= self.start
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "ContractWire", rename_all = "kebab-case")]
pub struct Contract {
regions: Vec<Region>,
min_permille: u16,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct ContractWire {
regions: Vec<Region>,
#[serde(default = "full")]
min_permille: u16,
}
fn full() -> u16 {
FULL_COVERAGE_PERMILLE
}
impl TryFrom<ContractWire> for Contract {
type Error = ContractError;
fn try_from(w: ContractWire) -> Result<Self, ContractError> {
Contract::new(w.regions, w.min_permille)
}
}
impl Contract {
pub fn new(regions: Vec<Region>, min_permille: u16) -> Result<Self, ContractError> {
if regions.is_empty() {
return Err(ContractError::NoRegions);
}
if regions.len() > MAX_CONTRACT_REGIONS {
return Err(ContractError::TooManyRegions(regions.len()));
}
if min_permille == 0 || min_permille > FULL_COVERAGE_PERMILLE {
return Err(ContractError::BadThreshold(min_permille));
}
Ok(Self {
regions,
min_permille,
})
}
#[must_use]
pub fn regions(&self) -> &[Region] {
&self.regions
}
#[must_use]
pub fn min_permille(&self) -> u16 {
self.min_permille
}
#[must_use]
pub fn touched_by_span(&self, from: u32, to: u32) -> u8 {
let mut bits = 0u8;
for (i, r) in self.regions.iter().enumerate() {
if r.overlaps(from, to) {
bits |= 1 << i;
}
}
bits
}
#[must_use]
pub fn touched_by_line(&self, line: u32) -> u8 {
self.touched_by_span(line, line)
}
#[must_use]
pub fn evaluate(&self, bits: u8) -> Coverage {
let required = self.regions.len();
let touched = (0..required).filter(|i| bits & (1 << i) != 0).count();
let missed = (0..required).filter(|i| bits & (1 << i) == 0).collect();
let permille = u16::try_from(touched * usize::from(FULL_COVERAGE_PERMILLE) / required)
.expect("touched ≤ required ≤ 8 keeps permille ≤ 1000");
Coverage {
required,
touched,
permille,
met: permille >= self.min_permille,
missed,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Coverage {
pub required: usize,
pub touched: usize,
pub permille: u16,
pub met: bool,
pub missed: Vec<usize>,
}
#[cfg(test)]
mod tests {
use super::*;
fn region(start: u32, end: u32) -> Region {
Region::new(None, start, end, 0).unwrap()
}
#[test]
fn construction_rejects_what_the_spec_rejects() {
assert_eq!(Contract::new(vec![], 1000), Err(ContractError::NoRegions));
let nine = (1..=9).map(|i| region(i * 10, i * 10 + 5)).collect();
assert_eq!(
Contract::new(nine, 1000),
Err(ContractError::TooManyRegions(9))
);
assert!(matches!(
Region::new(None, 0, 5, 3),
Err(ContractError::BadRange { index: 3, .. })
));
assert!(matches!(
Region::new(None, 9, 5, 0),
Err(ContractError::BadRange { .. })
));
assert_eq!(
Contract::new(vec![region(1, 2)], 0),
Err(ContractError::BadThreshold(0))
);
assert_eq!(
Contract::new(vec![region(1, 2)], 1001),
Err(ContractError::BadThreshold(1001))
);
assert!(matches!(
Region::new(Some("x".repeat(81)), 1, 2, 5),
Err(ContractError::LabelTooLong(5))
));
}
#[test]
fn touch_bits_are_declaration_order_indexed() {
let c = Contract::new(vec![region(10, 20), region(30, 40), region(50, 60)], 1000).unwrap();
assert_eq!(c.touched_by_span(1, 9), 0b000);
assert_eq!(c.touched_by_span(15, 35), 0b011);
assert_eq!(c.touched_by_span(20, 30), 0b011, "inclusive at both edges");
assert_eq!(c.touched_by_line(50), 0b100);
assert_eq!(c.touched_by_span(1, 100), 0b111);
}
#[test]
fn evaluate_thresholds_and_misses() {
let c = Contract::new(vec![region(1, 10), region(20, 30), region(40, 50)], 667).unwrap();
let none = c.evaluate(0);
assert!(!none.met);
assert_eq!(none.missed, vec![0, 1, 2]);
let two = c.evaluate(0b101);
assert_eq!(two.touched, 2);
assert_eq!(two.permille, 666);
assert!(!two.met, "666 < 667 — permille floors, honestly");
let all = c.evaluate(0b111);
assert!(all.met);
assert!(all.missed.is_empty());
assert_eq!(all.permille, 1000);
}
#[test]
fn serde_validates_on_the_way_in_and_roundtrips() {
let c = Contract::new(
vec![Region::new(Some("Pricing".into()), 847, 920, 0).unwrap()],
900,
)
.unwrap();
let json = serde_json::to_string(&c).unwrap();
let back: Contract = serde_json::from_str(&json).unwrap();
assert_eq!(back, c);
let d: Contract = serde_json::from_str(r#"{"regions":[{"start":1,"end":5}]}"#).unwrap();
assert_eq!(d.min_permille(), FULL_COVERAGE_PERMILLE);
assert!(serde_json::from_str::<Contract>(r#"{"regions":[]}"#).is_err());
assert!(serde_json::from_str::<Contract>(r#"{"regions":[{"start":9,"end":5}]}"#).is_err());
}
}