use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind};
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, PartialEq, Eq)]
pub struct AdrMeta {
pub id: String,
pub title: String,
pub status: AdrStatus,
}
#[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>,
}
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());
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());
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 mut id = None;
let mut status = AdrStatus::Draft;
let mut fm_title = 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()),
_ => {}
}
}
let id = id
.filter(|s| !s.is_empty())
.ok_or(ParseError::MissingAdrId)?;
let title = fm_title
.filter(|s| !s.is_empty())
.or_else(|| first_h1(body))
.unwrap_or_else(|| format!("ADR-{id}"));
let mut sections = Vec::new();
let mut links = Vec::new();
let mut current: Option<String> = None;
let mut in_fence = false;
for line in body.lines() {
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();
let slug = crate::text::slugify(&title);
current = Some(slug.clone());
sections.push(Section { slug, title });
}
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 },
path: rel_path.to_owned(),
sections,
links,
})
}
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),
},
}
}
fn first_h1(body: &str) -> Option<String> {
body.lines()
.find_map(|l| l.strip_prefix("# ").map(|h| h.trim().to_owned()))
}
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
}
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 = lang_for(path);
Some(format!("sym:{lang}:{path}#{symbol}"))
}
None => Some(format!("file:{path}")),
}
}
fn lang_for(path: &str) -> &str {
match path.rsplit_once('.').map(|(_, ext)| ext) {
Some("rs") => "rust",
Some(other) => other,
None => "text",
}
}
#[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_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");
}
}