use std::collections::HashMap;
use std::fmt;
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Must,
Should,
May,
}
impl Level {
fn sort_key(self) -> u8 {
match self {
Level::Must => 0,
Level::Should => 1,
Level::May => 2,
}
}
fn rust_variant(self) -> &'static str {
match self {
Level::Must => "Level::Must",
Level::Should => "Level::Should",
Level::May => "Level::May",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Applicability {
Universal,
Conditional {
condition: Option<String>,
antecedent: Option<Antecedent>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Antecedent {
pub audit_id: String,
}
#[derive(Debug, Clone)]
pub struct ParsedRequirement {
pub id: String,
pub principle: u8,
pub level: Level,
pub summary: String,
pub applicability: Applicability,
}
#[derive(Debug)]
pub enum ParseError {
UnterminatedFrontmatter {
file: String,
},
YamlError {
file: String,
message: String,
},
InvalidPrincipleId {
file: String,
value: String,
},
DuplicateId {
id: String,
file_a: String,
file_b: String,
},
UnknownLevel {
file: String,
requirement_id: String,
value: String,
},
UnknownApplicability {
file: String,
requirement_id: String,
hint: String,
},
MissingField {
file: String,
requirement_id: Option<String>,
field: String,
},
InvalidUniversal {
file: String,
requirement_id: String,
value: String,
},
EmptyRequirements {
file: String,
},
ConditionalEmpty {
file: String,
requirement_id: String,
},
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::UnterminatedFrontmatter { file } => write!(
f,
"{file}: frontmatter not terminated — expected closing `---` line"
),
ParseError::YamlError { file, message } => {
write!(f, "{file}: YAML parse error: {message}")
}
ParseError::InvalidPrincipleId { file, value } => write!(
f,
"{file}: file-level `id` must be `pN` (e.g., `p1`), got `{value}`"
),
ParseError::DuplicateId { id, file_a, file_b } => write!(
f,
"duplicate requirement id `{id}` in `{file_a}` and `{file_b}` — ids must be unique across all principles"
),
ParseError::UnknownLevel {
file,
requirement_id,
value,
} => write!(
f,
"{file}: requirement `{requirement_id}` has unknown level `{value}` — must be one of `must`, `should`, `may`"
),
ParseError::UnknownApplicability {
file,
requirement_id,
hint,
} => write!(
f,
"{file}: requirement `{requirement_id}` has unsupported `applicability` shape — {hint}"
),
ParseError::MissingField {
file,
requirement_id,
field,
} => match requirement_id {
Some(id) => write!(
f,
"{file}: requirement `{id}` is missing required field `{field}`"
),
None => write!(f, "{file}: missing required top-level field `{field}`"),
},
ParseError::InvalidUniversal {
file,
requirement_id,
value,
} => write!(
f,
"{file}: requirement `{requirement_id}` has bare-string `applicability: {value}` — only `universal` is accepted as a bare string; conditional forms must use `applicability: {{ if: \"<prose>\" }}`"
),
ParseError::EmptyRequirements { file } => {
write!(f, "{file}: `requirements` list is empty")
}
ParseError::ConditionalEmpty {
file,
requirement_id,
} => write!(
f,
"{file}: requirement `{requirement_id}` declares `kind: conditional` but provides neither `condition:` nor `antecedent.audit_id` — at least one must be set"
),
}
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Deserialize)]
struct RawFrontmatter {
id: String,
requirements: Vec<RawRequirement>,
#[serde(flatten)]
_extra: serde_yaml::Mapping,
}
#[derive(Debug, Deserialize)]
struct RawRequirement {
id: Option<String>,
level: Option<serde_yaml::Value>,
applicability: Option<serde_yaml::Value>,
summary: Option<String>,
#[serde(flatten)]
_extra: serde_yaml::Mapping,
}
pub fn parse_principle_file(
file_path: &str,
content: &str,
) -> Result<Vec<ParsedRequirement>, ParseError> {
let yaml_block = extract_frontmatter(file_path, content)?;
let raw: RawFrontmatter =
serde_yaml::from_str(yaml_block).map_err(|e| ParseError::YamlError {
file: file_path.to_string(),
message: e.to_string(),
})?;
let principle = parse_principle_id(file_path, &raw.id)?;
if raw.requirements.is_empty() {
return Err(ParseError::EmptyRequirements {
file: file_path.to_string(),
});
}
let mut out = Vec::with_capacity(raw.requirements.len());
for raw_req in raw.requirements {
let id = raw_req.id.ok_or_else(|| ParseError::MissingField {
file: file_path.to_string(),
requirement_id: None,
field: "id".into(),
})?;
let level = parse_level(file_path, &id, raw_req.level.as_ref())?;
let summary = raw_req.summary.ok_or_else(|| ParseError::MissingField {
file: file_path.to_string(),
requirement_id: Some(id.clone()),
field: "summary".into(),
})?;
let applicability = parse_applicability(file_path, &id, raw_req.applicability.as_ref())?;
out.push(ParsedRequirement {
id,
principle,
level,
summary,
applicability,
});
}
Ok(out)
}
pub fn aggregate(
parsed: Vec<(String, Vec<ParsedRequirement>)>,
) -> Result<Vec<ParsedRequirement>, ParseError> {
let mut seen: HashMap<String, String> = HashMap::new();
let mut all: Vec<ParsedRequirement> = Vec::new();
for (file, reqs) in parsed {
for req in reqs {
if let Some(existing_file) = seen.get(&req.id) {
return Err(ParseError::DuplicateId {
id: req.id.clone(),
file_a: existing_file.clone(),
file_b: file.clone(),
});
}
seen.insert(req.id.clone(), file.clone());
all.push(req);
}
}
all.sort_by_key(|r| (r.principle, r.level.sort_key()));
Ok(all)
}
pub fn emit_rust(reqs: &[ParsedRequirement], spec_version: &str) -> String {
let mut out = String::new();
out.push_str(
"// @generated by build.rs from src/principles/spec/principles/*.md.\n\
// Do not edit by hand — rerun `cargo build` (or `scripts/sync-spec.sh` then `cargo build`)\n\
// to regenerate.\n\n",
);
out.push_str(
"/// Every MUST/SHOULD/MAY in the spec, generated from vendored frontmatter.\n\
/// Sort order: principle ascending, then level (MUST → SHOULD → MAY),\n\
/// preserving spec source order within a level.\n",
);
out.push_str("pub static REQUIREMENTS: &[Requirement] = &[\n");
for r in reqs {
out.push_str(" Requirement {\n");
out.push_str(&format!(" id: \"{}\",\n", escape_rust_str(&r.id)));
out.push_str(&format!(" principle: {},\n", r.principle));
out.push_str(&format!(" level: {},\n", r.level.rust_variant()));
out.push_str(&format!(
" summary: \"{}\",\n",
escape_rust_str(&r.summary)
));
match &r.applicability {
Applicability::Universal => {
out.push_str(" applicability: Applicability::Universal,\n");
}
Applicability::Conditional {
condition,
antecedent,
} => {
let cond_lit = match condition {
Some(c) => format!("Some(\"{}\")", escape_rust_str(c)),
None => "None".to_string(),
};
let ante_lit = match antecedent {
Some(a) => format!(
"Some(Antecedent {{ audit_id: \"{}\" }})",
escape_rust_str(&a.audit_id)
),
None => "None".to_string(),
};
out.push_str(&format!(
" applicability: Applicability::Conditional {{ condition: {cond_lit}, antecedent: {ante_lit} }},\n"
));
}
}
out.push_str(" },\n");
}
out.push_str("];\n\n");
out.push_str("/// Spec version vendored under `src/principles/spec/VERSION` at build time.\n");
out.push_str("#[allow(dead_code)]\n");
out.push_str(&format!(
"pub const SPEC_VERSION: &str = \"{}\";\n",
escape_rust_str(spec_version)
));
out
}
fn extract_frontmatter<'a>(file_path: &str, content: &'a str) -> Result<&'a str, ParseError> {
let trimmed = content.trim_start_matches('\u{feff}'); let after_first = trimmed
.strip_prefix("---\n")
.or_else(|| trimmed.strip_prefix("---\r\n"))
.ok_or_else(|| ParseError::UnterminatedFrontmatter {
file: file_path.to_string(),
})?;
let end_idx =
find_closing_fence(after_first).ok_or_else(|| ParseError::UnterminatedFrontmatter {
file: file_path.to_string(),
})?;
Ok(&after_first[..end_idx])
}
fn find_closing_fence(s: &str) -> Option<usize> {
let mut idx = 0;
for line in s.split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
if trimmed == "---" {
return Some(idx);
}
idx += line.len();
}
None
}
fn parse_principle_id(file: &str, raw: &str) -> Result<u8, ParseError> {
raw.strip_prefix('p')
.and_then(|s| s.parse::<u8>().ok())
.filter(|n| (1..=255).contains(n))
.ok_or_else(|| ParseError::InvalidPrincipleId {
file: file.to_string(),
value: raw.to_string(),
})
}
fn parse_level(
file: &str,
req_id: &str,
value: Option<&serde_yaml::Value>,
) -> Result<Level, ParseError> {
let value = value.ok_or_else(|| ParseError::MissingField {
file: file.to_string(),
requirement_id: Some(req_id.to_string()),
field: "level".into(),
})?;
let s = value.as_str().ok_or_else(|| ParseError::UnknownLevel {
file: file.to_string(),
requirement_id: req_id.to_string(),
value: format!("{value:?}"),
})?;
match s {
"must" => Ok(Level::Must),
"should" => Ok(Level::Should),
"may" => Ok(Level::May),
other => Err(ParseError::UnknownLevel {
file: file.to_string(),
requirement_id: req_id.to_string(),
value: other.to_string(),
}),
}
}
fn parse_applicability(
file: &str,
req_id: &str,
value: Option<&serde_yaml::Value>,
) -> Result<Applicability, ParseError> {
let value = value.ok_or_else(|| ParseError::MissingField {
file: file.to_string(),
requirement_id: Some(req_id.to_string()),
field: "applicability".into(),
})?;
if let Some(s) = value.as_str() {
if s == "universal" {
return Ok(Applicability::Universal);
}
return Err(ParseError::InvalidUniversal {
file: file.to_string(),
requirement_id: req_id.to_string(),
value: s.to_string(),
});
}
if let Some(map) = value.as_mapping() {
let if_key = serde_yaml::Value::String("if".into());
let kind_key = serde_yaml::Value::String("kind".into());
if map.contains_key(&if_key) && map.contains_key(&kind_key) {
return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`if:` and `kind:` cannot both be set; choose one (legacy `{ if: \"<prose>\" }` or new `{ kind: conditional, antecedent: { audit_id: ... } }`)".into(),
});
}
if map.len() == 1
&& let Some(if_val) = map.get(&if_key)
{
let cond = if_val
.as_str()
.ok_or_else(|| ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`if:` value must be a non-empty string".into(),
})?;
if cond.is_empty() {
return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`if:` value must be a non-empty string".into(),
});
}
return Ok(Applicability::Conditional {
condition: Some(cond.to_string()),
antecedent: None,
});
}
if let Some(kind_val) = map.get(&kind_key) {
let kind = kind_val
.as_str()
.ok_or_else(|| ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`kind:` value must be the string `conditional`".into(),
})?;
if kind != "conditional" {
return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: format!("unknown `kind: {kind}` — only `conditional` is supported"),
});
}
let antecedent_key = serde_yaml::Value::String("antecedent".into());
let condition_key = serde_yaml::Value::String("condition".into());
let antecedent = if let Some(ante_val) = map.get(&antecedent_key) {
let ante_map =
ante_val
.as_mapping()
.ok_or_else(|| ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`antecedent:` must be a mapping like `{ audit_id: <id> }`"
.into(),
})?;
let audit_id_key = serde_yaml::Value::String("audit_id".into());
for (k, _) in ante_map {
let k_str = k.as_str().unwrap_or("<non-string>");
if k_str != "audit_id" {
return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: format!(
"`antecedent.{k_str}` is not part of the v1 schema (only `audit_id` is permitted; compound antecedents deferred to v2 per plan Sub-decision 2b)"
),
});
}
}
let audit_id = ante_map.get(&audit_id_key).and_then(|v| v.as_str()).ok_or_else(|| ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`antecedent.audit_id` must be a non-empty string (v1 schema supports a single antecedent only)".into(),
})?;
if audit_id.trim().is_empty() {
return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`antecedent.audit_id` must be a non-empty string (whitespace-only is rejected)".into(),
});
}
Some(Antecedent {
audit_id: audit_id.to_string(),
})
} else {
None
};
let condition = match map.get(&condition_key) {
Some(v) => {
let s = v.as_str().ok_or_else(|| ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "`condition:` must be a string".into(),
})?;
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
None => None,
};
if condition.is_none() && antecedent.is_none() {
return Err(ParseError::ConditionalEmpty {
file: file.to_string(),
requirement_id: req_id.to_string(),
});
}
return Ok(Applicability::Conditional {
condition,
antecedent,
});
}
return Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "expected `{ if: \"<prose>\" }` or `{ kind: conditional, antecedent: { audit_id: <id> } }`".into(),
});
}
Err(ParseError::UnknownApplicability {
file: file.to_string(),
requirement_id: req_id.to_string(),
hint: "must be `universal`, `{ if: \"<prose>\" }`, or `{ kind: conditional, antecedent: { audit_id: <id> } }`".into(),
})
}
fn escape_rust_str(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out
}