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,
pub text: 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 preamble: String,
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 text_for_key(&self, key: &str) -> Option<&str> {
let rest = key.strip_prefix(&self.key())?;
let text = if rest.is_empty() {
self.preamble.as_str()
} else {
let slug = rest.strip_prefix('#')?;
&self.sections.iter().find(|s| s.slug == slug)?.text
};
(!text.is_empty()).then_some(text)
}
#[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() });
if let Some(content) = stored(&self.preamble) {
adr.meta["content"] = content;
}
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());
if let Some(content) = stored(§ion.text) {
node.meta = serde_json::json!({ "content": content });
}
fs = fs.with_node(node).with_edge(Edge::authored(
adr_key.clone(),
key,
EdgeKind::Contains,
));
}
fs
}
}
fn stored(text: &str) -> Option<serde_json::Value> {
let capped = rto_graph::cap_content(text);
(!capped.is_empty()).then(|| serde_json::Value::from(capped))
}
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 scan = scan_body(&id, body, body_line1);
Ok(AdrDoc {
meta: AdrMeta {
id,
title,
status,
version: fm_version,
},
path: rel_path.to_owned(),
sections: scan.sections,
preamble: scan.preamble,
links: scan.links,
versions: scan.versions,
})
}
struct BodyScan {
preamble: String,
sections: Vec<Section>,
links: Vec<WikiLink>,
versions: VersionFacts,
}
fn scan_body(id: &str, body: &str, body_line1: usize) -> BodyScan {
let mut sections: Vec<Section> = 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;
let mut byte_offset = 0usize;
let mut span_start = 0usize;
let mut preamble_end: Option<usize> = None;
for (line_idx, line) in body.lines().enumerate() {
let line_start = byte_offset;
byte_offset += line.len();
if body[byte_offset..].starts_with("\r\n") {
byte_offset += 2;
} else if body[byte_offset..].starts_with('\n') {
byte_offset += 1;
}
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());
match sections.last_mut() {
Some(prev) => {
crate::text::trim_blank_lines(&body[span_start..line_start])
.clone_into(&mut prev.text);
}
None => preamble_end = Some(line_start),
}
span_start = byte_offset;
sections.push(Section {
slug,
title,
text: String::new(),
});
}
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 + line_idx;
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,
});
}
}
}
if let Some(last) = sections.last_mut() {
crate::text::trim_blank_lines(&body[span_start..]).clone_into(&mut last.text);
}
let preamble =
crate::text::trim_blank_lines(&body[..preamble_end.unwrap_or(body.len())]).to_owned();
BodyScan {
preamble,
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;
const SPANS: &str = "---\nadr-id: \"0015\"\nstatus: Accepted\n---\n\n# ADR-0015: Spans\n\n| | |\n|---|---|\n| **State** | Accepted |\n\n## Context\n\nALPHA the context prose.\n\n```md\n## Not A Heading\nALPHA fenced.\n```\n\n## Consequences\n\nBRAVO the consequences prose.\n\n### A subheading\n\nBRAVO more.\n\n## Example\n\n CHARLIE indented code;\n\nCHARLIE prose. \n\n## Empty\n";
#[test]
fn a_section_carries_its_own_body_and_not_the_document() {
let doc = parse_adr("docs/adr/0015-spans.md", SPANS).expect("parse");
let by = |slug: &str| {
doc.sections
.iter()
.find(|s| s.slug == slug)
.unwrap_or_else(|| panic!("no section {slug}"))
};
let context = &by("context").text;
assert!(
context.contains("ALPHA the context prose."),
"the section keeps its own prose: {context:?}"
);
assert!(
!context.contains("BRAVO"),
"and not the next section's: {context:?}"
);
let consequences = &by("consequences").text;
assert!(
consequences.contains("BRAVO the consequences prose."),
"{consequences:?}"
);
assert!(
consequences.contains("### A subheading"),
"a `###` inside the span is body text, not a boundary: {consequences:?}"
);
assert!(
!consequences.contains("ALPHA"),
"and not the previous section's: {consequences:?}"
);
assert!(
!context.contains("## Consequences"),
"the boundary heading is excluded: {context:?}"
);
assert!(
!consequences.starts_with("## "),
"a section note is already titled by its heading: {consequences:?}"
);
assert_eq!(
doc.sections
.iter()
.map(|s| s.slug.as_str())
.collect::<Vec<_>>(),
["context", "consequences", "example", "empty"],
"a fenced `## ` line does not open a section"
);
assert!(
context.contains("## Not A Heading"),
"the fenced line stays inside the section that encloses it: {context:?}"
);
assert_eq!(by("empty").text, "");
}
#[test]
fn the_preamble_is_the_span_that_belongs_to_no_section() {
let doc = parse_adr("docs/adr/0015-spans.md", SPANS).expect("parse");
assert!(
doc.preamble.contains("# ADR-0015: Spans"),
"{:?}",
doc.preamble
);
assert!(
doc.preamble.contains("| **State** | Accepted |"),
"the summary table is ADR-level, not section-level: {:?}",
doc.preamble
);
assert!(
!doc.preamble.contains("ALPHA") && !doc.preamble.contains("BRAVO"),
"no section body: {:?}",
doc.preamble
);
assert!(!doc.preamble.contains("adr-id"), "{:?}", doc.preamble);
}
#[test]
fn a_sectionless_adr_is_all_preamble() {
let doc = parse_adr(
"docs/adr/0099-x.md",
"---\nadr-id: \"0099\"\n---\n\n# ADR-0099\n\nJust prose.\n",
)
.expect("parse");
assert!(doc.sections.is_empty());
assert!(doc.preamble.contains("Just prose."), "{:?}", doc.preamble);
}
#[test]
fn facts_store_the_section_text_capped() {
let long = "x".repeat(4000);
let src = format!(
"---\nadr-id: \"0021\"\nstatus: Accepted\n---\n\n# ADR-0021\n\n## Context\n\n{long}\n"
);
let doc = parse_adr("docs/adr/0021-x.md", &src).expect("parse");
let facts = doc.facts();
let section = facts
.nodes
.iter()
.find(|n| n.key == "adr:0021#context")
.expect("section node");
let stored = section.meta["content"].as_str().expect("content");
assert_eq!(
stored.chars().count(),
1500,
"capped by the same budget the derived layer uses"
);
assert!(
doc.sections[0].text.chars().count() > stored.chars().count(),
"the parsed span itself stays whole — only the store is capped"
);
let adr = facts
.nodes
.iter()
.find(|n| n.key == "adr:0021")
.expect("adr node");
assert_eq!(adr.meta["status"], "Accepted");
assert!(
adr.meta["content"]
.as_str()
.expect("preamble")
.contains("ADR-0021"),
"{:?}",
adr.meta
);
assert!(
!adr.meta["content"]
.as_str()
.expect("preamble")
.contains("xxxx"),
"the ADR node does not restate its sections: {:?}",
adr.meta
);
}
#[test]
fn an_empty_section_stores_no_content_key() {
let doc = parse_adr("docs/adr/0015-spans.md", SPANS).expect("parse");
let facts = doc.facts();
let empty = facts
.nodes
.iter()
.find(|n| n.key == "adr:0015#empty")
.expect("node");
assert!(empty.meta.get("content").is_none(), "{:?}", empty.meta);
}
#[test]
fn text_for_key_maps_a_key_back_to_its_span() {
let doc = parse_adr("docs/adr/0015-spans.md", SPANS).expect("parse");
assert!(
doc.text_for_key("adr:0015")
.expect("preamble")
.contains("# ADR-0015: Spans")
);
assert!(
doc.text_for_key("adr:0015#consequences")
.expect("section")
.contains("BRAVO the consequences prose.")
);
assert!(
!doc.text_for_key("adr:0015#consequences")
.expect("section")
.contains("ALPHA"),
"a section key never resolves to the document"
);
assert_eq!(doc.text_for_key("adr:0015#empty"), None);
assert_eq!(doc.text_for_key("adr:0015#nosuch"), None);
assert_eq!(doc.text_for_key("adr:0016#context"), None);
assert_eq!(doc.text_for_key("file:docs/adr/0015-spans.md"), None);
assert_eq!(doc.text_for_key("adr:00151"), None);
}
#[test]
fn a_span_keeps_the_indentation_of_its_first_content_line() {
let doc = parse_adr("docs/adr/0015-spans.md", SPANS).expect("parse");
let example = doc
.sections
.iter()
.find(|s| s.slug == "example")
.expect("no section example");
assert_eq!(
example.text,
" CHARLIE indented code;\n\nCHARLIE prose. "
);
assert_eq!(
doc.text_for_key("adr:0015#example"),
Some(" CHARLIE indented code;\n\nCHARLIE prose. ")
);
}
#[test]
fn the_preamble_keeps_the_indentation_of_its_first_content_line() {
let doc = parse_adr(
"docs/adr/0016-indented.md",
"---\nadr-id: \"0016\"\nstatus: Draft\n---\n\n DELTA indented preamble;\n\n## Context\n\nprose.\n",
)
.expect("parse");
assert_eq!(doc.preamble, " DELTA indented preamble;");
}
#[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");
}
}