use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AdrStatus {
Draft,
ForReview,
Accepted,
Rejected,
Superseded,
}
impl AdrStatus {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Draft => "Draft",
Self::ForReview => "For Review",
Self::Accepted => "Accepted",
Self::Rejected => "Rejected",
Self::Superseded => "Superseded",
}
}
#[must_use]
pub fn is_active(self) -> bool {
matches!(self, Self::Draft | Self::ForReview | Self::Accepted)
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ParseError {
#[error("unknown ADR status: {0}")]
UnknownStatus(String),
#[error("missing required frontmatter field: adr-id")]
MissingAdrId,
}
impl std::str::FromStr for AdrStatus {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Draft" => Ok(Self::Draft),
"For Review" => Ok(Self::ForReview),
"Accepted" => Ok(Self::Accepted),
"Rejected" => Ok(Self::Rejected),
"Superseded" => Ok(Self::Superseded),
other => Err(ParseError::UnknownStatus(other.to_owned())),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct DocVersion {
pub major: u32,
pub minor: u32,
}
impl DocVersion {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
let (major, minor) = s.split_once('.')?;
let digits = |p: &str| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit());
if !digits(major) || !digits(minor) {
return None;
}
Some(Self {
major: major.parse().ok()?,
minor: minor.parse().ok()?,
})
}
fn parse_prefix(s: &str) -> Option<Self> {
let b = s.as_bytes();
let run = |from: usize| {
let mut i = from;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
i
};
let major_end = run(0);
if major_end == 0 || b.get(major_end) != Some(&b'.') {
return None;
}
let minor_end = run(major_end + 1);
if minor_end == major_end + 1 || b.get(minor_end) == Some(&b'.') {
return None;
}
Self::parse(&s[..minor_end])
}
}
impl std::fmt::Display for DocVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}", self.major, self.minor)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineVersionRef {
pub line: usize,
pub version: DocVersion,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VersionFacts {
pub summary_row: Option<DocVersion>,
pub history: Vec<DocVersion>,
pub inline_refs: Vec<InlineVersionRef>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdrMeta {
pub id: String,
pub title: String,
pub status: AdrStatus,
pub version: Option<DocVersion>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Section {
pub slug: String,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WikiLink {
pub from: String,
pub raw: String,
pub target_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdrDoc {
pub meta: AdrMeta,
pub path: String,
pub sections: Vec<Section>,
pub links: Vec<WikiLink>,
pub versions: VersionFacts,
}
impl AdrDoc {
#[must_use]
pub fn key(&self) -> String {
format!("adr:{}", self.meta.id)
}
#[must_use]
pub fn facts(&self) -> FactSet {
let adr_key = self.key();
let mut adr = Node::new(adr_key.clone(), NodeKind::Adr, self.meta.title.clone())
.with_provenance(Provenance::Authored);
adr.path = Some(self.path.clone());
adr.meta = serde_json::json!({ "status": self.meta.status.as_str() });
let mut fs = FactSet::new().with_node(adr);
for section in &self.sections {
let key = format!("{adr_key}#{}", section.slug);
let mut node = Node::new(key.clone(), NodeKind::AdrSection, section.title.clone())
.with_provenance(Provenance::Authored);
node.path = Some(self.path.clone());
fs = fs.with_node(node).with_edge(Edge::authored(
adr_key.clone(),
key,
EdgeKind::Contains,
));
}
fs
}
}
pub fn parse_adr(rel_path: &str, text: &str) -> Result<AdrDoc, ParseError> {
let (frontmatter, body) = split_frontmatter(text);
let body_offset = text.len() - body.len();
let body_line1 = text[..body_offset].lines().count() + 1;
let mut id = None;
let mut status = AdrStatus::Draft;
let mut fm_title = None;
let mut fm_version = None;
for line in frontmatter.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once(':') else {
continue;
};
let value = clean_value(value);
match key.trim().to_ascii_lowercase().as_str() {
"adr-id" => id = Some(value.to_owned()),
"status" if !value.is_empty() => status = value.parse()?,
"title" => fm_title = Some(value.to_owned()),
"version" => fm_version = DocVersion::parse(value),
_ => {}
}
}
let id = id
.filter(|s| !s.is_empty())
.ok_or(ParseError::MissingAdrId)?;
let title = fm_title
.filter(|s| !s.is_empty())
.or_else(|| crate::text::first_h1(body))
.unwrap_or_else(|| format!("ADR-{id}"));
let mut sections = Vec::new();
let mut links = Vec::new();
let mut versions = VersionFacts::default();
let mut current: Option<String> = None;
let mut in_fence = false;
let mut in_history = false;
for (offset, line) in body.lines().enumerate() {
if line.trim_start().starts_with("```") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
if let Some(heading) = line.strip_prefix("## ") {
let title = heading.trim().to_owned();
in_history = is_version_history(&title);
let slug = crate::text::slugify(&title);
current = Some(slug.clone());
sections.push(Section { slug, title });
}
if in_history {
versions.history.extend(history_row_version(line));
} else {
versions.summary_row = versions.summary_row.or_else(|| summary_row_version(line));
let file_line = body_line1 + offset;
versions
.inline_refs
.extend(inline_version_refs(line).map(|version| InlineVersionRef {
line: file_line,
version,
}));
}
for raw in crate::text::scan_wiki_links(line) {
let from = match ¤t {
Some(slug) => format!("adr:{id}#{slug}"),
None => format!("adr:{id}"),
};
if let Some(target_key) = resolve_target(&raw) {
links.push(WikiLink {
from,
raw,
target_key,
});
}
}
}
Ok(AdrDoc {
meta: AdrMeta {
id,
title,
status,
version: fm_version,
},
path: rel_path.to_owned(),
sections,
links,
versions,
})
}
fn is_version_history(title: &str) -> bool {
title.eq_ignore_ascii_case("Document version history")
|| title.eq_ignore_ascii_case("Version history")
}
fn history_row_version(line: &str) -> Option<DocVersion> {
let rest = line.trim_start().strip_prefix('|')?;
let (first, _) = rest.split_once('|')?;
DocVersion::parse(first.trim())
}
fn summary_row_version(line: &str) -> Option<DocVersion> {
let mut cells = line.trim_start().strip_prefix('|')?.split('|');
if cells.next()?.trim() != "**Document version**" {
return None;
}
DocVersion::parse(cells.next()?.trim())
}
fn inline_version_refs(line: &str) -> impl Iterator<Item = DocVersion> + '_ {
const MARK: &str = "(Update, v";
line.match_indices(MARK)
.filter_map(|(i, _)| DocVersion::parse_prefix(&line[i + MARK.len()..]))
}
pub(crate) fn split_frontmatter(text: &str) -> (&str, &str) {
let Some(rest) = text.strip_prefix("---\n") else {
return ("", text);
};
match rest.find("\n---\n") {
Some(end) => (&rest[..end], &rest[end + 5..]),
None => match rest.strip_suffix("\n---") {
Some(fm) => (fm, ""),
None => ("", text),
},
}
}
pub(crate) fn clean_value(raw: &str) -> &str {
let raw = raw.trim();
if raw.starts_with('"') || raw.starts_with('\'') {
return strip_quotes(raw);
}
match raw.find(" #") {
Some(idx) => raw[..idx].trim_end(),
None => raw,
}
}
fn strip_quotes(s: &str) -> &str {
for q in ['"', '\''] {
if let Some(inner) = s.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
return inner;
}
}
s
}
pub(crate) fn resolve_target(raw: &str) -> Option<String> {
let (path, symbol) = match raw.split_once('#') {
Some((p, s)) => (p.trim(), Some(s.trim())),
None => (raw.trim(), None),
};
if path.is_empty() {
return None;
}
match symbol.filter(|s| !s.is_empty()) {
Some(symbol) => {
let lang = crate::text::lang_for(path);
Some(format!("sym:{lang}:{path}#{symbol}"))
}
None => Some(format!("file:{path}")),
}
}
#[cfg(test)]
mod tests {
use super::{AdrStatus, parse_adr};
use crate::text::slugify;
#[test]
fn parses_all_house_statuses() {
for (s, want) in [
("Draft", AdrStatus::Draft),
("For Review", AdrStatus::ForReview),
("Accepted", AdrStatus::Accepted),
("Rejected", AdrStatus::Rejected),
("Superseded", AdrStatus::Superseded),
] {
assert_eq!(s.parse::<AdrStatus>().expect("parse"), want);
}
}
#[test]
fn rejects_unknown_status() {
assert!("Pending".parse::<AdrStatus>().is_err());
}
const ADR: &str = "---\nTitle: Example decision\ntype: adr\n# a comment line\nadr-id: \"0007\"\nstatus: Accepted\n---\n\n# ADR-0007: Example decision\n\n## Context\n\nThis relates to [[crates/rto-graph/src/store.rs#Store]].\n\n## Decision\n\nSee [[docs/adr/0001-x.md]] and a broken one [[]].\n";
#[test]
fn parses_frontmatter_sections_and_links() {
let doc = parse_adr("docs/adr/0007-example.md", ADR).expect("parse");
assert_eq!(doc.meta.id, "0007");
assert_eq!(doc.meta.title, "Example decision");
assert_eq!(doc.meta.status, AdrStatus::Accepted);
let slugs: Vec<_> = doc.sections.iter().map(|s| s.slug.as_str()).collect();
assert_eq!(slugs, ["context", "decision"]);
assert_eq!(doc.links.len(), 2);
assert_eq!(doc.links[0].from, "adr:0007#context");
assert_eq!(
doc.links[0].target_key,
"sym:rust:crates/rto-graph/src/store.rs#Store"
);
assert_eq!(doc.links[1].from, "adr:0007#decision");
assert_eq!(doc.links[1].target_key, "file:docs/adr/0001-x.md");
}
#[test]
fn adr_facts_carry_status_and_sections() {
let doc = parse_adr("docs/adr/0007-example.md", ADR).expect("parse");
let fs = doc.facts();
assert!(fs.nodes.iter().any(|n| n.key == "adr:0007"));
assert!(fs.nodes.iter().any(|n| n.key == "adr:0007#context"));
let adr = fs
.nodes
.iter()
.find(|n| n.key == "adr:0007")
.expect("adr node");
assert_eq!(adr.meta["status"], "Accepted");
assert!(
fs.nodes
.iter()
.all(|n| n.provenance == rto_graph::Provenance::Authored),
"ADR nodes must be Authored"
);
assert_eq!(fs.edges.iter().filter(|e| e.src == "adr:0007").count(), 2);
}
#[test]
fn missing_adr_id_is_an_error() {
let text = "---\nTitle: No id\nstatus: Draft\n---\n\n# Body\n";
assert_eq!(
parse_adr("x.md", text),
Err(super::ParseError::MissingAdrId)
);
}
#[test]
fn slugify_collapses_punctuation() {
assert_eq!(
slugify("Options considered + consequences"),
"options-considered-consequences"
);
assert_eq!(slugify(" Reference "), "reference");
}
#[test]
fn an_adr_title_falling_back_to_its_h1_carries_no_markup() {
let adr = parse_adr(
"docs/adr/0021-x.md",
"---\nadr-id: 0021\nstatus: Accepted\n---\n\n# Sandboxed *linting* {#lint}\n",
)
.expect("parse");
assert_eq!(adr.meta.title, "Sandboxed linting");
}
}