1use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17pub const MAX_CONTRACT_REGIONS: usize = 8;
20
21pub const FULL_COVERAGE_PERMILLE: u16 = 1000;
23
24const MAX_LABEL_LEN: usize = 80;
27
28#[derive(Debug, Error, PartialEq, Eq)]
30pub enum ContractError {
31 #[error("a contract needs at least one region")]
33 NoRegions,
34 #[error("{0} regions exceeds the {MAX_CONTRACT_REGIONS}-region cap — merge adjacent ranges")]
36 TooManyRegions(usize),
37 #[error("region {index}: lines {start}-{end} is not a 1-based inclusive range")]
40 BadRange {
41 index: usize,
43 start: u32,
45 end: u32,
47 },
48 #[error("min-permille {0} outside 1..=1000 (1000 = every region)")]
50 BadThreshold(u16),
51 #[error("region {0}: label exceeds {MAX_LABEL_LEN} characters")]
53 LabelTooLong(usize),
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(try_from = "RegionWire")]
61pub struct Region {
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 label: Option<String>,
64 start: u32,
65 end: u32,
66}
67
68#[derive(Deserialize)]
72struct RegionWire {
73 #[serde(default)]
74 label: Option<String>,
75 start: u32,
76 end: u32,
77}
78
79impl TryFrom<RegionWire> for Region {
80 type Error = ContractError;
81 fn try_from(w: RegionWire) -> Result<Self, ContractError> {
82 Region::new(w.label, w.start, w.end, 0)
83 }
84}
85
86impl Region {
87 pub fn new(
89 label: Option<String>,
90 start: u32,
91 end: u32,
92 index: usize,
93 ) -> Result<Self, ContractError> {
94 if start == 0 || start > end {
95 return Err(ContractError::BadRange { index, start, end });
96 }
97 if label.as_deref().is_some_and(|l| l.len() > MAX_LABEL_LEN) {
98 return Err(ContractError::LabelTooLong(index));
99 }
100 Ok(Self { label, start, end })
101 }
102
103 #[must_use]
105 pub fn label(&self) -> Option<&str> {
106 self.label.as_deref()
107 }
108
109 #[must_use]
111 pub fn start(&self) -> u32 {
112 self.start
113 }
114
115 #[must_use]
117 pub fn end(&self) -> u32 {
118 self.end
119 }
120
121 #[must_use]
124 pub fn overlaps(&self, from: u32, to: u32) -> bool {
125 from <= self.end && to >= self.start
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(try_from = "ContractWire", rename_all = "kebab-case")]
133pub struct Contract {
134 regions: Vec<Region>,
135 min_permille: u16,
136}
137
138#[derive(Deserialize)]
139#[serde(rename_all = "kebab-case")]
140struct ContractWire {
141 regions: Vec<Region>,
142 #[serde(default = "full")]
143 min_permille: u16,
144}
145
146fn full() -> u16 {
147 FULL_COVERAGE_PERMILLE
148}
149
150impl TryFrom<ContractWire> for Contract {
151 type Error = ContractError;
152 fn try_from(w: ContractWire) -> Result<Self, ContractError> {
153 Contract::new(w.regions, w.min_permille)
154 }
155}
156
157impl Contract {
158 pub fn new(regions: Vec<Region>, min_permille: u16) -> Result<Self, ContractError> {
160 if regions.is_empty() {
161 return Err(ContractError::NoRegions);
162 }
163 if regions.len() > MAX_CONTRACT_REGIONS {
164 return Err(ContractError::TooManyRegions(regions.len()));
165 }
166 if min_permille == 0 || min_permille > FULL_COVERAGE_PERMILLE {
167 return Err(ContractError::BadThreshold(min_permille));
168 }
169 Ok(Self {
170 regions,
171 min_permille,
172 })
173 }
174
175 #[must_use]
178 pub fn regions(&self) -> &[Region] {
179 &self.regions
180 }
181
182 #[must_use]
184 pub fn min_permille(&self) -> u16 {
185 self.min_permille
186 }
187
188 #[must_use]
191 pub fn touched_by_span(&self, from: u32, to: u32) -> u8 {
192 let mut bits = 0u8;
193 for (i, r) in self.regions.iter().enumerate() {
194 if r.overlaps(from, to) {
195 bits |= 1 << i;
196 }
197 }
198 bits
199 }
200
201 #[must_use]
203 pub fn touched_by_line(&self, line: u32) -> u8 {
204 self.touched_by_span(line, line)
205 }
206
207 #[must_use]
213 pub fn evaluate(&self, bits: u8) -> Coverage {
214 let required = self.regions.len();
215 let touched = (0..required).filter(|i| bits & (1 << i) != 0).count();
216 let missed = (0..required).filter(|i| bits & (1 << i) == 0).collect();
217 let permille = u16::try_from(touched * usize::from(FULL_COVERAGE_PERMILLE) / required)
218 .expect("touched ≤ required ≤ 8 keeps permille ≤ 1000");
219 Coverage {
220 required,
221 touched,
222 permille,
223 met: permille >= self.min_permille,
224 missed,
225 }
226 }
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
233pub struct Coverage {
234 pub required: usize,
236 pub touched: usize,
238 pub permille: u16,
240 pub met: bool,
242 pub missed: Vec<usize>,
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn region(start: u32, end: u32) -> Region {
252 Region::new(None, start, end, 0).unwrap()
253 }
254
255 #[test]
256 fn construction_rejects_what_the_spec_rejects() {
257 assert_eq!(Contract::new(vec![], 1000), Err(ContractError::NoRegions));
258 let nine = (1..=9).map(|i| region(i * 10, i * 10 + 5)).collect();
259 assert_eq!(
260 Contract::new(nine, 1000),
261 Err(ContractError::TooManyRegions(9))
262 );
263 assert!(matches!(
264 Region::new(None, 0, 5, 3),
265 Err(ContractError::BadRange { index: 3, .. })
266 ));
267 assert!(matches!(
268 Region::new(None, 9, 5, 0),
269 Err(ContractError::BadRange { .. })
270 ));
271 assert_eq!(
272 Contract::new(vec![region(1, 2)], 0),
273 Err(ContractError::BadThreshold(0))
274 );
275 assert_eq!(
276 Contract::new(vec![region(1, 2)], 1001),
277 Err(ContractError::BadThreshold(1001))
278 );
279 assert!(matches!(
280 Region::new(Some("x".repeat(81)), 1, 2, 5),
281 Err(ContractError::LabelTooLong(5))
282 ));
283 }
284
285 #[test]
286 fn touch_bits_are_declaration_order_indexed() {
287 let c = Contract::new(vec![region(10, 20), region(30, 40), region(50, 60)], 1000).unwrap();
288 assert_eq!(c.touched_by_span(1, 9), 0b000);
289 assert_eq!(c.touched_by_span(15, 35), 0b011);
290 assert_eq!(c.touched_by_span(20, 30), 0b011, "inclusive at both edges");
291 assert_eq!(c.touched_by_line(50), 0b100);
292 assert_eq!(c.touched_by_span(1, 100), 0b111);
293 }
294
295 #[test]
296 fn evaluate_thresholds_and_misses() {
297 let c = Contract::new(vec![region(1, 10), region(20, 30), region(40, 50)], 667).unwrap();
298 let none = c.evaluate(0);
299 assert!(!none.met);
300 assert_eq!(none.missed, vec![0, 1, 2]);
301 let two = c.evaluate(0b101);
302 assert_eq!(two.touched, 2);
303 assert_eq!(two.permille, 666);
304 assert!(!two.met, "666 < 667 — permille floors, honestly");
305 let all = c.evaluate(0b111);
306 assert!(all.met);
307 assert!(all.missed.is_empty());
308 assert_eq!(all.permille, 1000);
309 }
310
311 #[test]
312 fn serde_validates_on_the_way_in_and_roundtrips() {
313 let c = Contract::new(
314 vec![Region::new(Some("Pricing".into()), 847, 920, 0).unwrap()],
315 900,
316 )
317 .unwrap();
318 let json = serde_json::to_string(&c).unwrap();
319 let back: Contract = serde_json::from_str(&json).unwrap();
320 assert_eq!(back, c);
321 let d: Contract = serde_json::from_str(r#"{"regions":[{"start":1,"end":5}]}"#).unwrap();
323 assert_eq!(d.min_permille(), FULL_COVERAGE_PERMILLE);
324 assert!(serde_json::from_str::<Contract>(r#"{"regions":[]}"#).is_err());
326 assert!(serde_json::from_str::<Contract>(r#"{"regions":[{"start":9,"end":5}]}"#).is_err());
327 }
328}