use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
pub const POLICY_TOML: &str = include_str!("../policy/policy.toml");
pub const LEXICONS: &[(&str, &str)] = &[
(
"words/agent-loop.txt",
include_str!("../policy/words/agent-loop.txt"),
),
(
"words/assistant-offers.txt",
include_str!("../policy/words/assistant-offers.txt"),
),
(
"words/assistant-voice.txt",
include_str!("../policy/words/assistant-voice.txt"),
),
(
"words/audience-runway.txt",
include_str!("../policy/words/audience-runway.txt"),
),
(
"words/clarity-meta.txt",
include_str!("../policy/words/clarity-meta.txt"),
),
(
"words/copula-avoidance.txt",
include_str!("../policy/words/copula-avoidance.txt"),
),
(
"words/cutoff-disclaimers.txt",
include_str!("../policy/words/cutoff-disclaimers.txt"),
),
(
"words/empty-qualifiers.txt",
include_str!("../policy/words/empty-qualifiers.txt"),
),
(
"words/era-overuse.txt",
include_str!("../policy/words/era-overuse.txt"),
),
(
"words/filler-meta.txt",
include_str!("../policy/words/filler-meta.txt"),
),
(
"words/first-person.txt",
include_str!("../policy/words/first-person.txt"),
),
(
"words/hype-adjectives.txt",
include_str!("../policy/words/hype-adjectives.txt"),
),
(
"words/impact-framing.txt",
include_str!("../policy/words/impact-framing.txt"),
),
(
"words/importance-adjectives.txt",
include_str!("../policy/words/importance-adjectives.txt"),
),
(
"words/inflated-diction.txt",
include_str!("../policy/words/inflated-diction.txt"),
),
(
"words/injection.txt",
include_str!("../policy/words/injection.txt"),
),
(
"words/intensifiers.txt",
include_str!("../policy/words/intensifiers.txt"),
),
(
"words/magnitude-claims.txt",
include_str!("../policy/words/magnitude-claims.txt"),
),
(
"words/ornamental.txt",
include_str!("../policy/words/ornamental.txt"),
),
(
"words/pleasantries.txt",
include_str!("../policy/words/pleasantries.txt"),
),
(
"words/provenance-oblique.txt",
include_str!("../policy/words/provenance-oblique.txt"),
),
(
"words/provider-artifacts.txt",
include_str!("../policy/words/provider-artifacts.txt"),
),
(
"words/provider-attribution.txt",
include_str!("../policy/words/provider-attribution.txt"),
),
(
"words/reassurance.txt",
include_str!("../policy/words/reassurance.txt"),
),
("words/scrub.txt", include_str!("../policy/words/scrub.txt")),
(
"words/signature-lines.txt",
include_str!("../policy/words/signature-lines.txt"),
),
(
"words/significance-inflation.txt",
include_str!("../policy/words/significance-inflation.txt"),
),
(
"words/stock-openers.txt",
include_str!("../policy/words/stock-openers.txt"),
),
(
"words/tracking-params.txt",
include_str!("../policy/words/tracking-params.txt"),
),
(
"words/transition-openers.txt",
include_str!("../policy/words/transition-openers.txt"),
),
(
"words/vague-attribution.txt",
include_str!("../policy/words/vague-attribution.txt"),
),
(
"words/verification-claims.txt",
include_str!("../policy/words/verification-claims.txt"),
),
];
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Tier {
Violation,
Candidate,
CoverageHint,
}
impl Tier {
pub fn as_str(self) -> &'static str {
match self {
Tier::Violation => "violation",
Tier::Candidate => "candidate",
Tier::CoverageHint => "coverage_hint",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Lifecycle {
Blocking,
Advisory,
Experimental,
Deprecated,
}
impl Lifecycle {
pub fn as_str(self) -> &'static str {
match self {
Lifecycle::Blocking => "blocking",
Lifecycle::Advisory => "advisory",
Lifecycle::Experimental => "experimental",
Lifecycle::Deprecated => "deprecated",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum View {
Raw,
Prose,
Norm,
Rendered,
}
impl View {
pub fn as_str(self) -> &'static str {
match self {
View::Raw => "raw",
View::Prose => "prose",
View::Norm => "norm",
View::Rendered => "rendered",
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum MatchKindSpec {
WordSet,
RegexSet,
Structural,
Ratio,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Scope {
None,
LinkUrl,
Code,
Heading,
Comment,
}
use crate::Stance;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct FieldStance {
pub subject: Stance,
pub body: Stance,
pub trailers: Stance,
}
impl FieldStance {
pub fn uniform(s: Stance) -> FieldStance {
FieldStance {
subject: s,
body: s,
trailers: s,
}
}
pub fn for_field(&self, field: crate::Field) -> Stance {
match field {
crate::Field::Whole | crate::Field::Subject => self.subject,
crate::Field::Body => self.body,
crate::Field::Trailers => self.trailers,
}
}
pub fn any_active(&self) -> bool {
self.subject != Stance::Off || self.body != Stance::Off || self.trailers != Stance::Off
}
}
#[derive(Clone, Debug)]
pub struct Rule {
pub id: String,
pub name: String,
pub family: String,
pub tier: Tier,
pub lifecycle: Lifecycle,
pub origin: String,
pub human_only_waiver: bool,
pub view: View,
pub kind: MatchKindSpec,
pub lexicon: Option<String>,
pub terms: Vec<String>,
pub case_sensitive: bool,
pub boundary_word: bool,
pub block_start: bool,
pub scope: Scope,
pub params: toml::Value,
pub patterns: Vec<String>,
pub guard: String,
pub judge: Option<String>,
pub stances: Vec<FieldStance>,
pub exemptions: Vec<String>,
pub profile_exemptions: Vec<Vec<String>>,
}
impl Rule {
pub fn stance(&self, profile: crate::Profile, field: crate::Field) -> Stance {
self.stances[profile.index()].for_field(field)
}
pub fn profile_mask(&self) -> u16 {
let mut mask = 0u16;
for (i, fs) in self.stances.iter().enumerate() {
if fs.any_active() {
mask |= 1 << i;
}
}
mask
}
}
#[derive(Clone, Debug)]
pub struct ProfileDef {
pub name: String,
pub format: String,
pub core_rules: BTreeMap<String, String>,
pub notes: String,
}
#[derive(Clone, Debug)]
pub struct PolicyPackage {
pub version: String,
pub digest: String,
pub quotation_downgrade: Vec<String>,
pub quotation_suppress: Vec<String>,
pub profile_names: Vec<String>,
pub profiles: Vec<ProfileDef>,
pub rules: Vec<Rule>,
}
impl PolicyPackage {
pub fn rule_by_id(&self, id: &str) -> Option<&Rule> {
self.rules.iter().find(|r| r.id == id)
}
}
pub fn canonical_bytes() -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(b"policy.toml\0");
let toml_lf = POLICY_TOML.replace("\r\n", "\n");
for line in toml_lf.split_inclusive('\n') {
if line.trim_start().starts_with("digest = ") {
out.extend_from_slice(b"digest = \"\"\n");
} else {
out.extend_from_slice(line.as_bytes());
}
}
let mut files: Vec<(&str, &str)> = LEXICONS.to_vec();
files.sort_by_key(|(p, _)| *p);
for (path, content) in files {
out.push(0);
out.extend_from_slice(path.as_bytes());
out.push(0);
out.extend_from_slice(content.replace("\r\n", "\n").as_bytes());
}
out
}
pub fn compute_digest() -> String {
let mut hasher = Sha256::new();
hasher.update(canonical_bytes());
format!("sha256:{:x}", hasher.finalize())
}
fn lexicon_terms(path: &str) -> Result<Vec<String>, String> {
let (_, content) = LEXICONS
.iter()
.find(|(p, _)| *p == path)
.ok_or_else(|| format!("lexicon {path} is not embedded"))?;
let mut terms = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
terms.push(line.to_string());
}
if terms.is_empty() {
return Err(format!("lexicon {path} has no terms"));
}
Ok(terms)
}
fn as_str(v: &toml::Value, what: &str) -> Result<String, String> {
v.as_str()
.map(|s| s.to_string())
.ok_or_else(|| format!("{what} must be a string"))
}
fn parse_stance(s: &str, what: &str) -> Result<Stance, String> {
match s {
"apply" => Ok(Stance::Apply),
"relax" => Ok(Stance::Relax),
"off" => Ok(Stance::Off),
other => Err(format!("{what}: unknown stance {other}")),
}
}
fn parse_field_stance(v: &toml::Value, what: &str) -> Result<FieldStance, String> {
match v {
toml::Value::String(s) => Ok(FieldStance::uniform(parse_stance(s, what)?)),
toml::Value::Table(t) => {
let get = |k: &str| -> Result<Stance, String> {
match t.get(k) {
Some(toml::Value::String(s)) => parse_stance(s, what),
Some(_) => Err(format!("{what}.{k} must be a string")),
None => Ok(Stance::Apply),
}
};
Ok(FieldStance {
subject: get("subject")?,
body: get("body")?,
trailers: get("trailers")?,
})
}
_ => Err(format!("{what} must be a string or table")),
}
}
fn parse_profile_exemptions(
rt: &toml::value::Table,
profile_names: &[String],
id: &str,
) -> Result<Vec<Vec<String>>, String> {
let mut out = vec![Vec::new(); profile_names.len()];
let Some(v) = rt.get("profile_exemptions") else {
return Ok(out);
};
let table = v
.as_table()
.ok_or_else(|| format!("rule {id}: profile_exemptions must be a table"))?;
for (profile, literals) in table {
let idx = profile_names
.iter()
.position(|n| n == profile)
.ok_or_else(|| {
format!("rule {id}: profile_exemptions names unknown profile {profile}")
})?;
let arr = literals.as_array().ok_or_else(|| {
format!("rule {id}: profile_exemptions.{profile} must be an array of strings")
})?;
if arr.is_empty() {
return Err(format!(
"rule {id}: profile_exemptions.{profile} is an empty array"
));
}
for lit in arr {
let s = as_str(lit, "profile_exemptions literal")?;
if s.is_empty() {
return Err(format!(
"rule {id}: profile_exemptions.{profile} has an empty literal"
));
}
out[idx].push(s);
}
}
Ok(out)
}
fn validate_exempt_heading_sets(
params: &toml::Value,
profile_names: &[String],
id: &str,
) -> Result<(), String> {
let Some(v) = params.as_table().and_then(|t| t.get("exempt_heading_sets")) else {
return Ok(());
};
let table = v
.as_table()
.ok_or_else(|| format!("rule {id}: exempt_heading_sets must be a table"))?;
for (profile, sets) in table {
if !profile_names.iter().any(|n| n == profile) {
return Err(format!(
"rule {id}: exempt_heading_sets names unknown profile {profile}"
));
}
let sets = sets.as_array().ok_or_else(|| {
format!("rule {id}: exempt_heading_sets.{profile} must be an array of heading sets")
})?;
if sets.is_empty() {
return Err(format!(
"rule {id}: exempt_heading_sets.{profile} is an empty array"
));
}
for set in sets {
let set = set.as_array().ok_or_else(|| {
format!("rule {id}: exempt_heading_sets.{profile} sets must be arrays of strings")
})?;
if set.is_empty() {
return Err(format!(
"rule {id}: exempt_heading_sets.{profile} has an empty heading set"
));
}
for h in set {
let s = as_str(h, "exempt_heading_sets heading")?;
if s.is_empty() {
return Err(format!(
"rule {id}: exempt_heading_sets.{profile} has an empty heading literal"
));
}
if s != s.to_lowercase() {
return Err(format!(
"rule {id}: exempt_heading_sets.{profile} heading {s:?} must be lowercase"
));
}
}
}
}
Ok(())
}
pub fn load() -> Result<PolicyPackage, String> {
let root: toml::Value =
toml::from_str(POLICY_TOML).map_err(|e| format!("policy.toml parse: {e}"))?;
let table = root.as_table().ok_or("policy.toml root is not a table")?;
let policy_tbl = table
.get("policy")
.and_then(|v| v.as_table())
.ok_or("[policy] missing")?;
let version = policy_tbl
.get("version")
.and_then(|v| v.as_str())
.ok_or("[policy].version missing")?
.to_string();
let semantics = table
.get("semantics")
.and_then(|v| v.as_table())
.ok_or("[semantics] missing")?;
let quotation_downgrade = semantics
.get("quotation_downgrade")
.and_then(|v| v.as_array())
.ok_or("[semantics].quotation_downgrade missing")?
.iter()
.map(|v| as_str(v, "quotation_downgrade entry"))
.collect::<Result<Vec<_>, _>>()?;
let quotation_suppress = semantics
.get("quotation_suppress")
.and_then(|v| v.as_array())
.ok_or("[semantics].quotation_suppress missing")?
.iter()
.map(|v| as_str(v, "quotation_suppress entry"))
.collect::<Result<Vec<_>, _>>()?;
let profile_names = semantics
.get("profile_names")
.and_then(|v| v.as_array())
.ok_or("[semantics].profile_names missing")?
.iter()
.map(|v| as_str(v, "profile_names entry"))
.collect::<Result<Vec<_>, _>>()?;
if profile_names.len() != 9 {
return Err(format!(
"expected 9 profiles, found {}",
profile_names.len()
));
}
for (i, p) in crate::Profile::ALL.iter().enumerate() {
if profile_names[i] != p.as_str() {
return Err(format!(
"profile order mismatch at {i}: package says {}, crate says {}",
profile_names[i],
p.as_str()
));
}
}
let profile_tbl = table
.get("profile")
.and_then(|v| v.as_table())
.ok_or("[profile.*] missing")?;
let mut profiles = Vec::new();
for name in &profile_names {
let def = profile_tbl
.get(name)
.and_then(|v| v.as_table())
.ok_or_else(|| format!("[profile.{name}] missing"))?;
let format = def
.get("format")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("[profile.{name}].format missing"))?
.to_string();
let mut core_rules = BTreeMap::new();
if let Some(cr) = def.get("core_rules").and_then(|v| v.as_table()) {
for (k, v) in cr {
core_rules.insert(k.clone(), as_str(v, "core rule stance")?);
}
}
let notes = def
.get("notes")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
profiles.push(ProfileDef {
name: name.clone(),
format,
core_rules,
notes,
});
}
let rules_arr = table
.get("rule")
.and_then(|v| v.as_array())
.ok_or("[[rule]] entries missing")?;
let mut rules = Vec::new();
let mut lexicon_uses: BTreeMap<String, Vec<String>> = BTreeMap::new();
for rv in rules_arr {
let rt = rv.as_table().ok_or("rule entry is not a table")?;
let id = rt
.get("id")
.and_then(|v| v.as_str())
.ok_or("rule missing id")?
.to_string();
let get_str = |k: &str| -> Result<String, String> {
rt.get(k)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| format!("rule {id} missing {k}"))
};
let name = get_str("name")?;
let family = get_str("family")?;
let tier = match get_str("tier")?.as_str() {
"violation" => Tier::Violation,
"candidate" => Tier::Candidate,
"coverage_hint" => Tier::CoverageHint,
other => return Err(format!("rule {id}: unknown tier {other}")),
};
let lifecycle = match get_str("lifecycle")?.as_str() {
"blocking" => Lifecycle::Blocking,
"advisory" => Lifecycle::Advisory,
"experimental" => Lifecycle::Experimental,
"deprecated" => Lifecycle::Deprecated,
other => return Err(format!("rule {id}: unknown lifecycle {other}")),
};
let origin = get_str("origin")?;
let human_only_waiver = rt
.get("waiver")
.and_then(|v| v.as_str())
.map(|s| s == "human-only")
.unwrap_or(false);
let view = match get_str("view")?.as_str() {
"raw" => View::Raw,
"prose" => View::Prose,
"norm" => View::Norm,
"rendered" => View::Rendered,
other => return Err(format!("rule {id}: unknown view {other}")),
};
let match_tbl = rt
.get("match")
.and_then(|v| v.as_table())
.ok_or_else(|| format!("rule {id} missing match"))?;
let kind = match match_tbl.get("kind").and_then(|v| v.as_str()) {
Some("word-set") => MatchKindSpec::WordSet,
Some("regex-set") => MatchKindSpec::RegexSet,
Some("structural") => MatchKindSpec::Structural,
Some("ratio") => MatchKindSpec::Ratio,
other => return Err(format!("rule {id}: bad match kind {other:?}")),
};
let lexicon = match_tbl
.get("lexicon")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mut terms = Vec::new();
if let Some(path) = &lexicon {
lexicon_uses
.entry(path.clone())
.or_default()
.push(id.clone());
terms.extend(lexicon_terms(path)?);
}
if let Some(words) = match_tbl.get("words").and_then(|v| v.as_array()) {
for w in words {
terms.push(as_str(w, "match.words entry")?);
}
}
if kind == MatchKindSpec::WordSet && terms.is_empty() {
return Err(format!("word-set rule {id} has no terms"));
}
let case_sensitive = match match_tbl.get("case").and_then(|v| v.as_str()) {
Some("sensitive") => true,
Some("insensitive") | None => false,
Some(other) => return Err(format!("rule {id}: unknown case mode {other}")),
};
let boundary_word = match match_tbl.get("boundary").and_then(|v| v.as_str()) {
Some("word") => true,
Some("none") | None => false,
Some(other) => return Err(format!("rule {id}: unknown boundary {other}")),
};
let block_start = match match_tbl.get("position").and_then(|v| v.as_str()) {
Some("block-start") => true,
None => false,
Some(other) => return Err(format!("rule {id}: unknown position {other}")),
};
let scope = match match_tbl.get("scope").and_then(|v| v.as_str()) {
Some("link-url") => Scope::LinkUrl,
Some("code") => Scope::Code,
Some("heading") => Scope::Heading,
Some("comment") => Scope::Comment,
None => Scope::None,
Some(other) => return Err(format!("rule {id}: unknown scope {other}")),
};
let params = match_tbl
.get("params")
.cloned()
.unwrap_or(toml::Value::Table(Default::default()));
validate_exempt_heading_sets(¶ms, &profile_names, &id)?;
let mut patterns = Vec::new();
if let Some(pats) = rt.get("patterns").and_then(|v| v.as_array()) {
for p in pats {
patterns.push(as_str(p, "patterns entry")?);
}
}
if kind == MatchKindSpec::RegexSet && patterns.is_empty() {
return Err(format!("regex-set rule {id} has no patterns"));
}
let guard = get_str("guard")?;
let judge = rt
.get("judge")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if tier == Tier::Candidate && judge.is_none() {
return Err(format!("candidate rule {id} has no judge question"));
}
let profiles_val = rt
.get("profiles")
.and_then(|v| v.as_table())
.ok_or_else(|| format!("rule {id} missing profiles"))?;
let default_stance = match profiles_val.get("default") {
Some(v) => parse_field_stance(v, &format!("rule {id} profiles.default"))?,
None => FieldStance::uniform(Stance::Apply),
};
let mut stances = vec![default_stance; profile_names.len()];
for (k, v) in profiles_val {
if k == "default" {
continue;
}
let idx = profile_names
.iter()
.position(|n| n == k)
.ok_or_else(|| format!("rule {id}: unknown profile {k}"))?;
stances[idx] = parse_field_stance(v, &format!("rule {id} profiles.{k}"))?;
}
let mut exemptions = Vec::new();
if let Some(ex) = rt.get("exemptions").and_then(|v| v.as_table()) {
for (_, v) in ex {
if let Some(arr) = v.as_array() {
for phrase in arr {
exemptions.push(as_str(phrase, "exemption phrase")?.to_lowercase());
}
}
}
}
let profile_exemptions = parse_profile_exemptions(rt, &profile_names, &id)?;
rules.push(Rule {
id,
name,
family,
tier,
lifecycle,
origin,
human_only_waiver,
view,
kind,
lexicon,
terms,
case_sensitive,
boundary_word,
block_start,
scope,
params,
patterns,
guard,
judge,
stances,
exemptions,
profile_exemptions,
});
}
for (path, _) in LEXICONS {
match lexicon_uses.get(*path).map(|v| v.len()).unwrap_or(0) {
1 => {}
0 => return Err(format!("lexicon {path} is referenced by no rule")),
n => return Err(format!("lexicon {path} is referenced by {n} rules")),
}
}
let mut seen = std::collections::BTreeSet::new();
for r in &rules {
if !seen.insert(r.id.clone()) {
return Err(format!("duplicate rule id {}", r.id));
}
}
for id in quotation_downgrade.iter().chain(quotation_suppress.iter()) {
if !rules.iter().any(|r| &r.id == id) {
return Err(format!("quotation semantics list names unknown rule {id}"));
}
}
let digest = compute_digest();
Ok(PolicyPackage {
version,
digest,
quotation_downgrade,
quotation_suppress,
profile_names,
profiles,
rules,
})
}
#[cfg(test)]
mod profile_exemption_tests {
use super::parse_profile_exemptions;
fn names() -> Vec<String> {
crate::Profile::ALL
.iter()
.map(|p| p.as_str().to_string())
.collect()
}
fn rule_table(toml_src: &str) -> toml::value::Table {
toml::from_str::<toml::Value>(toml_src)
.unwrap()
.as_table()
.unwrap()
.clone()
}
#[test]
fn absent_table_yields_empty_lists() {
let rt = rule_table(r#"id = "SLOP-TEST""#);
let out = parse_profile_exemptions(&rt, &names(), "SLOP-TEST").unwrap();
assert_eq!(out.len(), 9);
assert!(out.iter().all(|l| l.is_empty()));
}
#[test]
fn literals_index_by_profile_order() {
let rt = rule_table(
r#"profile_exemptions = { internal-doc = ["DO NOT BUILD"], readme = ["A", "B"] }"#,
);
let out = parse_profile_exemptions(&rt, &names(), "SLOP-TEST").unwrap();
let internal = crate::Profile::InternalDoc.index();
let readme = crate::Profile::Readme.index();
assert_eq!(out[internal], vec!["DO NOT BUILD".to_string()]);
assert_eq!(out[readme], vec!["A".to_string(), "B".to_string()]);
assert!(out[crate::Profile::PublicBugReport.index()].is_empty());
}
#[test]
fn unknown_profile_name_is_a_parse_error() {
let rt = rule_table(r#"profile_exemptions = { not-a-profile = ["X"] }"#);
let err = parse_profile_exemptions(&rt, &names(), "SLOP-TEST").unwrap_err();
assert!(err.contains("unknown profile not-a-profile"), "{err}");
}
#[test]
fn non_array_value_and_empty_literal_are_parse_errors() {
let rt = rule_table(r#"profile_exemptions = { readme = "X" }"#);
assert!(parse_profile_exemptions(&rt, &names(), "SLOP-TEST").is_err());
let rt = rule_table(r#"profile_exemptions = { readme = [""] }"#);
assert!(parse_profile_exemptions(&rt, &names(), "SLOP-TEST").is_err());
}
#[test]
fn empty_exemption_array_is_a_parse_error() {
let rt = rule_table(r#"profile_exemptions = { internal-doc = [] }"#);
let err = parse_profile_exemptions(&rt, &names(), "SLOP-TEST").unwrap_err();
assert!(err.contains("empty array"), "{err}");
}
}
#[cfg(test)]
mod exempt_heading_sets_tests {
use super::validate_exempt_heading_sets;
fn names() -> Vec<String> {
crate::Profile::ALL
.iter()
.map(|p| p.as_str().to_string())
.collect()
}
fn params(toml_src: &str) -> toml::Value {
toml::from_str::<toml::Value>(toml_src).unwrap()
}
#[test]
fn known_profile_with_lowercase_headings_loads() {
let p =
params(r#"exempt_heading_sets = { public-bug-report = [["reproducer", "observed"]] }"#);
assert!(validate_exempt_heading_sets(&p, &names(), "SLOP-TEST").is_ok());
}
#[test]
fn unknown_profile_key_is_a_load_error() {
let p = params(r#"exempt_heading_sets = { not-a-profile = [["reproducer"]] }"#);
let err = validate_exempt_heading_sets(&p, &names(), "SLOP-TEST").unwrap_err();
assert!(err.contains("unknown profile not-a-profile"), "{err}");
}
#[test]
fn non_lowercase_heading_literal_is_a_load_error() {
let p = params(r#"exempt_heading_sets = { readme = [["Reproducer"]] }"#);
let err = validate_exempt_heading_sets(&p, &names(), "SLOP-TEST").unwrap_err();
assert!(err.contains("must be lowercase"), "{err}");
}
#[test]
fn empty_and_malformed_sets_are_load_errors() {
for src in [
r#"exempt_heading_sets = { readme = [] }"#,
r#"exempt_heading_sets = { readme = [[]] }"#,
r#"exempt_heading_sets = { readme = [[""]] }"#,
r#"exempt_heading_sets = { readme = ["reproducer"] }"#,
] {
let p = params(src);
assert!(
validate_exempt_heading_sets(&p, &names(), "SLOP-TEST").is_err(),
"{src} must fail the load"
);
}
}
}