1use std::{
8 collections::BTreeMap,
9 fs, io,
10 path::{Path, PathBuf},
11};
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::coverage_analysis::serialize_javascript_number;
17use crate::coverage_report::DecisionResult;
18
19pub const WAIVERS_FILE: &str = "supercov.waivers.json";
20pub const WAIVERS_SCHEMA_VERSION: u32 = 1;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct CoverageWaiver {
25 pub file: String,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub decision: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub line: Option<usize>,
30 pub condition: String,
31 pub reason: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct CoverageWaiverSource {
36 pub path: PathBuf,
37 pub waivers: Vec<CoverageWaiver>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct CoverageWaiverMatch {
43 pub waiver: CoverageWaiver,
44 pub decision_id: String,
45 pub file: String,
46 pub line: usize,
47 pub condition_index: usize,
48 pub condition_source: String,
49 pub covered: bool,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct CoverageWaiverEvaluation {
54 pub path: PathBuf,
55 pub waivers: Vec<CoverageWaiver>,
56 pub applied: Vec<CoverageWaiverMatch>,
57 pub contradicted: Vec<CoverageWaiverMatch>,
58 pub unmatched: Vec<CoverageWaiver>,
59 pub waived_by_decision: BTreeMap<String, BTreeMap<usize, CoverageWaiver>>,
60 pub applied_by_file: BTreeMap<String, usize>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64pub struct ContradictedWaiver {
65 pub file: String,
66 pub line: usize,
67 pub condition: String,
68 pub reason: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize)]
72#[serde(rename_all = "camelCase")]
73pub struct McdcExcludingWaived {
74 pub covered: usize,
75 pub total: usize,
76 #[serde(serialize_with = "serialize_javascript_number")]
77 pub percentage: f64,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize)]
81#[serde(rename_all = "camelCase")]
82pub struct CoverageWaiverSummary {
83 pub file: String,
84 pub entries: usize,
85 pub applied: usize,
86 pub contradicted: Vec<ContradictedWaiver>,
87 pub unmatched: Vec<CoverageWaiver>,
88 pub mcdc_excluding_waived: McdcExcludingWaived,
89}
90
91impl CoverageWaiverEvaluation {
92 pub fn summary(&self, covered: usize, total: usize) -> CoverageWaiverSummary {
93 let adjusted_total = total.saturating_sub(self.applied.len());
94 CoverageWaiverSummary {
95 file: WAIVERS_FILE.into(),
96 entries: self.waivers.len(),
97 applied: self.applied.len(),
98 contradicted: self
99 .contradicted
100 .iter()
101 .map(|matched| ContradictedWaiver {
102 file: matched.file.clone(),
103 line: matched.line,
104 condition: matched.condition_source.clone(),
105 reason: matched.waiver.reason.clone(),
106 })
107 .collect(),
108 unmatched: self.unmatched.clone(),
109 mcdc_excluding_waived: McdcExcludingWaived {
110 covered,
111 total: adjusted_total,
112 percentage: if adjusted_total > 0 {
113 covered as f64 / adjusted_total as f64 * 100.0
114 } else {
115 100.0
116 },
117 },
118 }
119 }
120}
121
122#[derive(Debug)]
123pub enum WaiverError {
124 Io { path: PathBuf, source: io::Error },
125 InvalidJson(serde_json::Error),
126 InvalidShape,
127 InvalidEntry { index: usize, problem: String },
128}
129
130impl std::fmt::Display for WaiverError {
131 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 match self {
133 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
134 Self::InvalidJson(error) => {
135 write!(formatter, "{WAIVERS_FILE} is not valid JSON: {error}")
136 }
137 Self::InvalidShape => write!(
138 formatter,
139 "{WAIVERS_FILE} must be {{\"version\": 1, \"waivers\": [...]}}"
140 ),
141 Self::InvalidEntry { index, problem } => {
142 write!(formatter, "{WAIVERS_FILE} waiver {} {problem}", index + 1)
143 }
144 }
145 }
146}
147
148impl std::error::Error for WaiverError {}
149
150fn entry_problem(index: usize, problem: impl Into<String>) -> WaiverError {
151 WaiverError::InvalidEntry {
152 index,
153 problem: problem.into(),
154 }
155}
156
157fn nonempty_string<'a>(object: &'a serde_json::Map<String, Value>, field: &str) -> Option<&'a str> {
158 object
159 .get(field)
160 .and_then(Value::as_str)
161 .filter(|value| !value.is_empty())
162}
163
164fn parse_waiver(value: &Value, index: usize) -> Result<CoverageWaiver, WaiverError> {
165 let object = value
166 .as_object()
167 .ok_or_else(|| entry_problem(index, "requires a non-empty file"))?;
168 let file = nonempty_string(object, "file")
169 .ok_or_else(|| entry_problem(index, "requires a non-empty file"))?;
170 let condition = nonempty_string(object, "condition")
171 .ok_or_else(|| entry_problem(index, "requires a non-empty condition"))?;
172 let reason = object
173 .get("reason")
174 .and_then(Value::as_str)
175 .filter(|value| !value.trim().is_empty())
176 .ok_or_else(|| entry_problem(index, "requires a non-empty reason"))?;
177 let decision = match object.get("decision") {
178 None => None,
179 Some(Value::String(value)) => Some(value.clone()),
180 Some(_) => return Err(entry_problem(index, "has a non-string decision")),
181 };
182 let line = match object.get("line") {
183 None => None,
184 Some(value) => value
185 .as_u64()
186 .and_then(|value| usize::try_from(value).ok())
187 .filter(|value| *value > 0)
188 .ok_or_else(|| entry_problem(index, "has a non-positive line"))?
189 .into(),
190 };
191 if positional_condition(condition).is_some() && decision.is_none() {
192 return Err(entry_problem(
193 index,
194 format!("uses the positional condition {condition} without a decision"),
195 ));
196 }
197 Ok(CoverageWaiver {
198 file: file.into(),
199 decision,
200 line,
201 condition: condition.into(),
202 reason: reason.into(),
203 })
204}
205
206pub fn read_coverage_waivers(root: &Path) -> Result<Option<CoverageWaiverSource>, WaiverError> {
207 let path = root.join(WAIVERS_FILE);
208 let raw = match fs::read_to_string(&path) {
209 Ok(raw) => raw,
210 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
211 Err(source) => return Err(WaiverError::Io { path, source }),
212 };
213 let parsed: Value = serde_json::from_str(&raw).map_err(WaiverError::InvalidJson)?;
214 let object = parsed.as_object().ok_or(WaiverError::InvalidShape)?;
215 if object.get("version").and_then(Value::as_u64) != Some(WAIVERS_SCHEMA_VERSION.into()) {
216 return Err(WaiverError::InvalidShape);
217 }
218 let values = object
219 .get("waivers")
220 .and_then(Value::as_array)
221 .ok_or(WaiverError::InvalidShape)?;
222 let waivers = values
223 .iter()
224 .enumerate()
225 .map(|(index, value)| parse_waiver(value, index))
226 .collect::<Result<Vec<_>, _>>()?;
227 Ok(Some(CoverageWaiverSource { path, waivers }))
228}
229
230fn positional_condition(value: &str) -> Option<usize> {
231 let digits = value.strip_prefix('C')?;
232 (!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
233 .then(|| digits.parse::<usize>().ok())
234 .flatten()
235}
236
237fn ecmascript_whitespace(character: char) -> bool {
238 matches!(
239 character,
240 '\u{0009}'..='\u{000d}'
241 | '\u{0020}'
242 | '\u{00a0}'
243 | '\u{1680}'
244 | '\u{2000}'..='\u{200a}'
245 | '\u{2028}'
246 | '\u{2029}'
247 | '\u{202f}'
248 | '\u{205f}'
249 | '\u{3000}'
250 | '\u{feff}'
251 )
252}
253
254fn normalized_source(source: &str) -> String {
255 let mut normalized = String::new();
256 let mut pending_space = false;
257 for character in source.chars() {
258 if ecmascript_whitespace(character) {
259 pending_space = !normalized.is_empty();
260 } else {
261 if pending_space {
262 normalized.push(' ');
263 pending_space = false;
264 }
265 normalized.push(character);
266 }
267 }
268 normalized
269}
270
271pub fn evaluate_coverage_waivers(
272 decisions: &[DecisionResult],
273 source: &CoverageWaiverSource,
274) -> CoverageWaiverEvaluation {
275 let mut evaluation = CoverageWaiverEvaluation {
276 path: source.path.clone(),
277 waivers: source.waivers.clone(),
278 applied: Vec::new(),
279 contradicted: Vec::new(),
280 unmatched: Vec::new(),
281 waived_by_decision: BTreeMap::new(),
282 applied_by_file: BTreeMap::new(),
283 };
284 for waiver in &source.waivers {
285 let mut matches = Vec::new();
286 for decision in decisions {
287 if decision.meta.file != waiver.file
288 || waiver.line.is_some_and(|line| line != decision.meta.line)
289 {
290 continue;
291 }
292 if let Some(selector) = &waiver.decision
293 && decision.meta.id != *selector
294 && normalized_source(&decision.meta.source) != normalized_source(selector)
295 {
296 continue;
297 }
298 for condition in &decision.conditions {
299 let positional = format!("C{}", condition.index + 1);
300 if waiver.condition != positional
301 && normalized_source(&condition.source) != normalized_source(&waiver.condition)
302 {
303 continue;
304 }
305 matches.push(CoverageWaiverMatch {
306 waiver: waiver.clone(),
307 decision_id: decision.meta.id.clone(),
308 file: decision.meta.file.clone(),
309 line: decision.meta.line,
310 condition_index: condition.index,
311 condition_source: condition.source.clone(),
312 covered: condition.covered,
313 });
314 }
315 }
316 if matches.is_empty() {
317 evaluation.unmatched.push(waiver.clone());
318 continue;
319 }
320 for matched in matches {
321 if matched.covered {
322 evaluation.contradicted.push(matched);
323 continue;
324 }
325 let conditions = evaluation
326 .waived_by_decision
327 .entry(matched.decision_id.clone())
328 .or_default();
329 if conditions.contains_key(&matched.condition_index) {
330 continue;
331 }
332 conditions.insert(matched.condition_index, matched.waiver.clone());
333 *evaluation
334 .applied_by_file
335 .entry(matched.file.clone())
336 .or_default() += 1;
337 evaluation.applied.push(matched);
338 }
339 }
340 evaluation
341}
342
343#[cfg(test)]
344mod tests {
345 use std::{
346 sync::atomic::{AtomicU64, Ordering},
347 time::{SystemTime, UNIX_EPOCH},
348 };
349
350 use crate::coverage_report::{CoverageConfidence, DecisionMeta};
351
352 use super::*;
353
354 static SEQUENCE: AtomicU64 = AtomicU64::new(0);
355
356 fn directory() -> PathBuf {
357 let nonce = SystemTime::now()
358 .duration_since(UNIX_EPOCH)
359 .unwrap()
360 .as_nanos();
361 let path = std::env::temp_dir().join(format!(
362 "supercov-waivers-{}-{nonce}-{}",
363 std::process::id(),
364 SEQUENCE.fetch_add(1, Ordering::Relaxed)
365 ));
366 fs::create_dir_all(&path).unwrap();
367 path
368 }
369
370 fn decision(covered: [bool; 2]) -> DecisionResult {
371 DecisionResult {
372 meta: DecisionMeta {
373 id: "decision-1".into(),
374 file: "src/example.ts".into(),
375 line: 12,
376 column: 3,
377 source: "ready\n &&\u{feff} enabled".into(),
378 conditions: vec!["ready".into(), "enabled".into()],
379 kind: "logical-and".into(),
380 },
381 executed: true,
382 covered: covered.into_iter().all(|value| value),
383 vectors: Vec::new(),
384 vector_observations: Vec::new(),
385 conditions: ["ready", "enabled"]
386 .into_iter()
387 .enumerate()
388 .map(|(index, source)| crate::coverage_report::ConditionResult {
389 index,
390 source: source.into(),
391 covered: covered[index],
392 assertion_covered: false,
393 witness: None,
394 witness_tests: None,
395 })
396 .collect(),
397 tests: Vec::new(),
398 confidence: CoverageConfidence {
399 level: "unexecuted".into(),
400 setup_only: false,
401 background_only: false,
402 asserted: false,
403 tests: Vec::new(),
404 asserted_tests: Vec::new(),
405 runners: Vec::new(),
406 kinds: Vec::new(),
407 e2e: false,
408 },
409 }
410 }
411
412 #[test]
413 fn absence_is_distinct_from_malformed_policy() {
414 let root = directory();
415 assert!(read_coverage_waivers(&root).unwrap().is_none());
416 fs::write(root.join(WAIVERS_FILE), "{").unwrap();
417 assert!(matches!(
418 read_coverage_waivers(&root),
419 Err(WaiverError::InvalidJson(_))
420 ));
421 fs::write(root.join(WAIVERS_FILE), r#"{"version":2,"waivers":[]}"#).unwrap();
422 assert!(matches!(
423 read_coverage_waivers(&root),
424 Err(WaiverError::InvalidShape)
425 ));
426 fs::remove_dir_all(root).unwrap();
427 }
428
429 #[test]
430 fn validates_each_frozen_entry_rule() {
431 let root = directory();
432 for (entry, problem) in [
433 (
434 r#"{"condition":"ready","reason":"why"}"#,
435 "requires a non-empty file",
436 ),
437 (
438 r#"{"file":"x","reason":"why"}"#,
439 "requires a non-empty condition",
440 ),
441 (
442 r#"{"file":"x","condition":"ready","reason":" "}"#,
443 "requires a non-empty reason",
444 ),
445 (
446 r#"{"file":"x","condition":"C1","reason":"why"}"#,
447 "uses the positional condition C1 without a decision",
448 ),
449 ] {
450 fs::write(
451 root.join(WAIVERS_FILE),
452 format!(r#"{{"version":1,"waivers":[{entry}]}}"#),
453 )
454 .unwrap();
455 assert_eq!(
456 read_coverage_waivers(&root).unwrap_err().to_string(),
457 format!("{WAIVERS_FILE} waiver 1 {problem}")
458 );
459 }
460 fs::remove_dir_all(root).unwrap();
461 }
462
463 #[test]
464 fn matches_id_ecmascript_whitespace_source_and_position() {
465 let source = CoverageWaiverSource {
466 path: PathBuf::from(WAIVERS_FILE),
467 waivers: vec![
468 CoverageWaiver {
469 file: "src/example.ts".into(),
470 decision: Some("ready && enabled".into()),
471 line: None,
472 condition: "enabled".into(),
473 reason: "source".into(),
474 },
475 CoverageWaiver {
476 file: "src/example.ts".into(),
477 decision: Some("decision-1".into()),
478 line: Some(12),
479 condition: "C1".into(),
480 reason: "position".into(),
481 },
482 ],
483 };
484 let evaluation = evaluate_coverage_waivers(&[decision([false, true])], &source);
485 assert_eq!(evaluation.applied.len(), 1);
486 assert_eq!(evaluation.applied[0].condition_index, 0);
487 assert_eq!(evaluation.contradicted.len(), 1);
488 assert_eq!(evaluation.contradicted[0].condition_index, 1);
489 assert!(evaluation.unmatched.is_empty());
490 }
491
492 #[test]
493 fn first_uncovered_waiver_owns_annotation_and_unknowns_remain_visible() {
494 let first = CoverageWaiver {
495 file: "src/example.ts".into(),
496 decision: Some("decision-1".into()),
497 line: None,
498 condition: "C1".into(),
499 reason: "first".into(),
500 };
501 let mut duplicate = first.clone();
502 duplicate.reason = "second".into();
503 let mut unknown = first.clone();
504 unknown.condition = "C9".into();
505 let source = CoverageWaiverSource {
506 path: PathBuf::from(WAIVERS_FILE),
507 waivers: vec![first, duplicate, unknown.clone()],
508 };
509 let evaluation = evaluate_coverage_waivers(&[decision([false, true])], &source);
510 assert_eq!(evaluation.applied.len(), 1);
511 assert_eq!(evaluation.applied[0].waiver.reason, "first");
512 assert_eq!(evaluation.unmatched, [unknown]);
513 assert_eq!(evaluation.applied_by_file["src/example.ts"], 1);
514 }
515}