use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::report::{
Classification, DecidedItem, Disposition, DispositionsReport, KindDispositions, Report,
};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DispositionsFile {
#[serde(default)]
pub items: BTreeMap<String, Entry>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entry {
pub status: Status,
#[serde(default, rename = "ref")]
pub reference: String,
#[serde(default)]
pub reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Covered,
Waived,
Undecided,
}
#[derive(Debug, thiserror::Error)]
pub enum DispositionsError {
#[error("cannot read {path}: {source}")]
Read {
path: String,
#[source]
source: std::io::Error,
},
#[error("{path} is not a dispositions file: {message}")]
Parse {
path: String,
message: String,
},
#[error(
"{id:?} uses a wildcard: each item needs its own disposition, never in bulk (ADR 0026, \
Decision 5)"
)]
Wildcard {
id: String,
},
#[error(
"{id:?} is not a finding id: use the report's `id` field, as in \
`policy:public.posts.Anyone can read posts`"
)]
NotAnId {
id: String,
},
#[error("{id:?} is waived without a reason")]
WaivedWithoutReason {
id: String,
},
#[error("{id:?} is covered without a `ref`")]
CoveredWithoutRef {
id: String,
},
}
impl DispositionsFile {
pub fn parse(text: &str) -> Result<Self, DispositionsError> {
Self::from_text(text, "the dispositions file")
}
pub fn load(path: &Path) -> Result<Self, DispositionsError> {
let text = std::fs::read_to_string(path).map_err(|source| DispositionsError::Read {
path: path.display().to_string(),
source,
})?;
Self::from_text(&text, &path.display().to_string())
}
fn from_text(text: &str, path: &str) -> Result<Self, DispositionsError> {
let file: Self = toml::from_str(text).map_err(|error| DispositionsError::Parse {
path: path.to_owned(),
message: error.to_string(),
})?;
file.validate()?;
Ok(file)
}
fn validate(&self) -> Result<(), DispositionsError> {
for (id, entry) in &self.items {
if id.contains('*') {
return Err(DispositionsError::Wildcard { id: id.clone() });
}
if !id.contains(':') {
return Err(DispositionsError::NotAnId { id: id.clone() });
}
match entry.status {
Status::Waived if entry.reason.trim().is_empty() => {
return Err(DispositionsError::WaivedWithoutReason { id: id.clone() });
}
Status::Covered if entry.reference.trim().is_empty() => {
return Err(DispositionsError::CoveredWithoutRef { id: id.clone() });
}
Status::Covered | Status::Waived | Status::Undecided => {}
}
}
Ok(())
}
}
pub fn apply(report: &mut Report, file: &DispositionsFile) {
let mut by_kind: BTreeMap<String, KindDispositions> = BTreeMap::new();
let mut decided = Vec::new();
let mut decided_status: BTreeMap<String, Disposition> = BTreeMap::new();
let mut needed: BTreeSet<String> = BTreeSet::new();
let mut undecided = 0;
for finding in &report.findings {
if finding.classification == Classification::Automatic {
continue;
}
needed.insert(finding.id.clone());
let disposition = file
.items
.get(&finding.id)
.map_or(Disposition::Undecided, |entry| match entry.status {
Status::Covered => Disposition::Covered,
Status::Waived => Disposition::Waived,
Status::Undecided => Disposition::Undecided,
});
let counts = by_kind
.entry(finding.kind.clone())
.or_insert_with(|| KindDispositions {
kind: finding.kind.clone(),
covered: 0,
waived: 0,
undecided: 0,
});
match disposition {
Disposition::Covered => counts.covered += 1,
Disposition::Waived => counts.waived += 1,
Disposition::Undecided => {
counts.undecided += 1;
undecided += 1;
}
}
if disposition != Disposition::Undecided {
let entry = &file.items[&finding.id];
decided_status.insert(finding.id.clone(), disposition);
let (reference, reason) = match disposition {
Disposition::Covered => (non_blank(&entry.reference), None),
Disposition::Waived | Disposition::Undecided => (None, non_blank(&entry.reason)),
};
decided.push(DecidedItem {
id: finding.id.clone(),
status: disposition,
reference,
reason,
});
}
}
decided.sort_by(|a, b| a.id.cmp(&b.id));
for policy in &mut report.policies {
let id = format!("policy:{}.{}.{}", policy.schema, policy.table, policy.name);
policy.disposition = decided_status
.get(&id)
.copied()
.unwrap_or(Disposition::Undecided);
}
report.summary.decided = decided.len();
report.summary.undecided = undecided;
report.dispositions = DispositionsReport {
by_kind: by_kind.into_values().collect(),
decided,
stale: file
.items
.keys()
.filter(|id| !needed.contains(*id))
.cloned()
.collect(),
};
}
#[must_use]
pub fn skeleton(report: &Report) -> String {
let mut out = String::new();
out.push_str(
"# Dispositions for the Supabase import (ADR 0026, Decision 5).\n\
#\n\
# One entry per item that needs work or is a blocker, keyed by the finding\n\
# id from the report. `covered` needs a `ref` to the code or test that\n\
# covers it; `waived` needs a `reason`. No wildcards: each item is its\n\
# own line. See docs/import/supabase.md.\n",
);
for finding in &report.findings {
if finding.classification == Classification::Automatic {
continue;
}
if report
.dispositions
.decided
.iter()
.any(|decided| decided.id == finding.id)
{
continue;
}
let _ = write!(
out,
"\n# {} ({}): {}\n[items.{}]\nstatus = \"undecided\"\nref = \"\"\nreason = \"\"\n",
finding.kind,
finding.classification.label(),
one_line(&finding.reason),
quoted(&finding.id),
);
}
out
}
fn quoted(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for character in text.chars() {
match character {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\u{8}' => out.push_str("\\b"),
'\t' => out.push_str("\\t"),
'\n' => out.push_str("\\n"),
'\u{c}' => out.push_str("\\f"),
'\r' => out.push_str("\\r"),
character if character.is_control() => {
let _ = write!(out, "\\u{:04X}", u32::from(character));
}
character => out.push(character),
}
}
out.push('"');
out
}
fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn non_blank(text: &str) -> Option<String> {
(!text.trim().is_empty()).then(|| text.to_owned())
}