use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::edges::{
CoAuthorityItem, ConstrainItem, ExtendItem, Origin, ReferenceItem, RefineItem, SupersedeItem,
};
use crate::error::{Error, Result};
use crate::unit::Unit;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Draft,
Approved,
Superseded,
Retired,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Risk {
Low,
Medium,
High,
Critical,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Implementation {
Pending,
InProgress,
Complete,
#[serde(rename = "n-a", alias = "n/a")]
Na,
Deferred,
}
#[derive(Clone, Debug)]
pub enum FrontmatterIssue {
Malformed(String),
UnrepresentableDeclared { key: String, detail: String },
}
impl From<FrontmatterIssue> for Error {
fn from(issue: FrontmatterIssue) -> Self {
match issue {
FrontmatterIssue::Malformed(m) => Error::Parse(m),
FrontmatterIssue::UnrepresentableDeclared { key, detail } => Error::Parse(format!(
"declared extra-frontmatter key '{key}' carries an unrepresentable YAML value: {detail}"
)),
}
}
}
pub const KNOWN_KEYS: &[&str] = &[
"id",
"title",
"status",
"created",
"summary",
"authors",
"owner",
"kind",
"domain",
"risk",
"implementation",
"depends_on",
"code_aliases",
"feature_branch",
"establishes",
"extends",
"refines",
"supersedes",
"amends",
"co_authority",
"constrains",
"references",
"superseded_by",
"retirement_rationale",
"amends_sections",
"unamendable",
"amendment_record",
"origin",
];
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Frontmatter {
pub id: String,
pub title: String,
pub status: Status,
pub created: String,
pub summary: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub authors: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub domain: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub risk: Option<Risk>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub implementation: Option<Implementation>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub code_aliases: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub feature_branch: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub establishes: Vec<Unit>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extends: Vec<ExtendItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub refines: Vec<RefineItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub supersedes: Vec<SupersedeItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub amends: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub co_authority: Vec<CoAuthorityItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub constrains: Vec<ConstrainItem>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub references: Vec<ReferenceItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retirement_rationale: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub amends_sections: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub unamendable: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amendment_record: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<Origin>,
#[serde(skip)]
pub extra_frontmatter: BTreeMap<String, serde_json::Value>,
}
pub fn split_frontmatter(src: &str) -> Result<(String, String)> {
let src = src.strip_prefix('\u{feff}').unwrap_or(src);
let mut lines = src.lines();
match lines.next() {
Some(first) if first.trim_end() == "---" => {}
_ => {
return Err(Error::Parse(
"spec.md must begin with a YAML frontmatter block delimited by '---'".into(),
));
}
}
let mut frontmatter = String::new();
let mut closed = false;
for line in lines.by_ref() {
if line.trim_end() == "---" {
closed = true;
break;
}
frontmatter.push_str(line);
frontmatter.push('\n');
}
if !closed {
return Err(Error::Parse(
"unterminated frontmatter block (missing closing '---')".into(),
));
}
let mut body = String::new();
for line in lines {
body.push_str(line);
body.push('\n');
}
Ok((frontmatter, body))
}
pub fn parse_frontmatter(src: &str) -> Result<Frontmatter> {
parse_frontmatter_with(src, &[]).map_err(Into::into)
}
pub fn parse_frontmatter_with(
src: &str,
declared: &[String],
) -> std::result::Result<Frontmatter, FrontmatterIssue> {
let malformed = |m: String| FrontmatterIssue::Malformed(m);
let (yaml, _body) = split_frontmatter(src).map_err(|e| {
malformed(match e {
Error::Parse(m) => m,
other => other.to_string(),
})
})?;
let value: serde_yaml::Value = serde_yaml::from_str(&yaml)
.map_err(|e| malformed(format!("invalid YAML frontmatter: {e}")))?;
let mapping = value
.as_mapping()
.ok_or_else(|| malformed("frontmatter must be a YAML mapping".into()))?;
let mut frontmatter: Frontmatter = serde_yaml::from_value(value.clone())
.map_err(|e| malformed(format!("invalid frontmatter: {e}")))?;
for (k, v) in mapping {
let key = match k.as_str() {
Some(s) => s,
None => return Err(malformed("frontmatter keys must be strings".into())),
};
if KNOWN_KEYS.contains(&key) {
continue;
}
let json = if declared.iter().any(|d| d == key) {
yaml_to_json(v).map_err(|detail| FrontmatterIssue::UnrepresentableDeclared {
key: key.to_string(),
detail,
})?
} else {
yaml_to_extra(v).map_err(malformed)?
};
if json.is_null() {
continue;
}
frontmatter.extra_frontmatter.insert(key.to_string(), json);
}
frontmatter.extends =
crate::edges::expand_extend_paths(std::mem::take(&mut frontmatter.extends))
.map_err(malformed)?;
frontmatter.refines =
crate::edges::expand_refine_paths(std::mem::take(&mut frontmatter.refines))
.map_err(malformed)?;
frontmatter.supersedes =
crate::edges::normalize_supersedes(std::mem::take(&mut frontmatter.supersedes));
Ok(frontmatter)
}
fn yaml_to_extra(v: &serde_yaml::Value) -> std::result::Result<serde_json::Value, String> {
use serde_yaml::Value;
match v {
Value::Null => Ok(serde_json::Value::Null),
Value::Bool(b) => Ok(serde_json::Value::Bool(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(serde_json::Value::from(i))
} else if let Some(f) = n.as_f64() {
serde_json::Number::from_f64(f)
.map(serde_json::Value::Number)
.ok_or_else(|| "unsupported numeric extra-frontmatter value".to_string())
} else {
Err("unsupported numeric extra-frontmatter value".to_string())
}
}
Value::String(s) => Ok(serde_json::Value::String(s.clone())),
Value::Sequence(seq) => {
let mut list = Vec::with_capacity(seq.len());
for item in seq {
match item.as_str() {
Some(s) => list.push(serde_json::Value::String(s.to_string())),
None => {
return Err("extra-frontmatter lists must contain only strings".to_string());
}
}
}
Ok(serde_json::Value::Array(list))
}
Value::Mapping(_) | Value::Tagged(_) => Err(
"extra-frontmatter values must be scalars or string lists, not nested maps".to_string(),
),
}
}
fn yaml_to_json(v: &serde_yaml::Value) -> std::result::Result<serde_json::Value, String> {
use serde_yaml::Value;
match v {
Value::Null => Ok(serde_json::Value::Null),
Value::Bool(b) => Ok(serde_json::Value::Bool(*b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(serde_json::Value::from(i))
} else if let Some(u) = n.as_u64() {
Ok(serde_json::Value::from(u))
} else if let Some(f) = n.as_f64() {
serde_json::Number::from_f64(f)
.map(serde_json::Value::Number)
.ok_or_else(|| format!("non-finite number {f} is not JSON-representable"))
} else {
Err("unsupported YAML number".to_string())
}
}
Value::String(s) => Ok(serde_json::Value::String(s.clone())),
Value::Sequence(seq) => seq
.iter()
.map(yaml_to_json)
.collect::<std::result::Result<Vec<_>, _>>()
.map(serde_json::Value::Array),
Value::Mapping(map) => {
let mut out = serde_json::Map::new();
for (mk, mv) in map {
let Some(key) = mk.as_str() else {
return Err("non-string mapping key is not JSON-representable".to_string());
};
out.insert(key.to_string(), yaml_to_json(mv)?);
}
Ok(serde_json::Value::Object(out))
}
Value::Tagged(tagged) => Err(format!(
"YAML tag '{}' is not JSON-representable",
tagged.tag
)),
}
}