1use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub enum AdrStatus {
16 Draft,
18 ForReview,
20 Accepted,
22 Rejected,
24 Superseded,
26}
27
28impl AdrStatus {
29 #[must_use]
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Self::Draft => "Draft",
34 Self::ForReview => "For Review",
35 Self::Accepted => "Accepted",
36 Self::Rejected => "Rejected",
37 Self::Superseded => "Superseded",
38 }
39 }
40
41 #[must_use]
44 pub fn is_active(self) -> bool {
45 matches!(self, Self::Draft | Self::ForReview | Self::Accepted)
46 }
47}
48
49#[derive(Debug, thiserror::Error, PartialEq, Eq)]
51pub enum ParseError {
52 #[error("unknown ADR status: {0}")]
54 UnknownStatus(String),
55 #[error("missing required frontmatter field: adr-id")]
57 MissingAdrId,
58}
59
60impl std::str::FromStr for AdrStatus {
61 type Err = ParseError;
62
63 fn from_str(s: &str) -> Result<Self, Self::Err> {
64 match s {
65 "Draft" => Ok(Self::Draft),
66 "For Review" => Ok(Self::ForReview),
67 "Accepted" => Ok(Self::Accepted),
68 "Rejected" => Ok(Self::Rejected),
69 "Superseded" => Ok(Self::Superseded),
70 other => Err(ParseError::UnknownStatus(other.to_owned())),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
81pub struct DocVersion {
82 pub major: u32,
84 pub minor: u32,
86}
87
88impl DocVersion {
89 #[must_use]
95 pub fn parse(s: &str) -> Option<Self> {
96 let (major, minor) = s.split_once('.')?;
97 let digits = |p: &str| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit());
98 if !digits(major) || !digits(minor) {
99 return None;
100 }
101 Some(Self {
102 major: major.parse().ok()?,
103 minor: minor.parse().ok()?,
104 })
105 }
106
107 fn parse_prefix(s: &str) -> Option<Self> {
110 let b = s.as_bytes();
111 let run = |from: usize| {
112 let mut i = from;
113 while i < b.len() && b[i].is_ascii_digit() {
114 i += 1;
115 }
116 i
117 };
118 let major_end = run(0);
119 if major_end == 0 || b.get(major_end) != Some(&b'.') {
120 return None;
121 }
122 let minor_end = run(major_end + 1);
123 if minor_end == major_end + 1 || b.get(minor_end) == Some(&b'.') {
124 return None;
125 }
126 Self::parse(&s[..minor_end])
127 }
128}
129
130impl std::fmt::Display for DocVersion {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 write!(f, "{}.{}", self.major, self.minor)
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct InlineVersionRef {
139 pub line: usize,
141 pub version: DocVersion,
143}
144
145#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct VersionFacts {
149 pub summary_row: Option<DocVersion>,
151 pub history: Vec<DocVersion>,
153 pub inline_refs: Vec<InlineVersionRef>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct AdrMeta {
162 pub id: String,
164 pub title: String,
166 pub status: AdrStatus,
168 pub version: Option<DocVersion>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct Section {
175 pub slug: String,
177 pub title: String,
179 pub text: String,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct WikiLink {
198 pub from: String,
200 pub raw: String,
202 pub target_key: String,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct AdrDoc {
209 pub meta: AdrMeta,
211 pub path: String,
213 pub sections: Vec<Section>,
215 pub preamble: String,
224 pub links: Vec<WikiLink>,
226 pub versions: VersionFacts,
228}
229
230impl AdrDoc {
231 #[must_use]
233 pub fn key(&self) -> String {
234 format!("adr:{}", self.meta.id)
235 }
236
237 #[must_use]
249 pub fn text_for_key(&self, key: &str) -> Option<&str> {
250 let rest = key.strip_prefix(&self.key())?;
251 let text = if rest.is_empty() {
252 self.preamble.as_str()
253 } else {
254 let slug = rest.strip_prefix('#')?;
257 &self.sections.iter().find(|s| s.slug == slug)?.text
258 };
259 (!text.is_empty()).then_some(text)
260 }
261
262 #[must_use]
267 pub fn facts(&self) -> FactSet {
268 let adr_key = self.key();
269 let mut adr = Node::new(adr_key.clone(), NodeKind::Adr, self.meta.title.clone())
270 .with_provenance(Provenance::Authored);
271 adr.path = Some(self.path.clone());
272 adr.meta = serde_json::json!({ "status": self.meta.status.as_str() });
273 if let Some(content) = stored(&self.preamble) {
274 adr.meta["content"] = content;
275 }
276 let mut fs = FactSet::new().with_node(adr);
277
278 for section in &self.sections {
279 let key = format!("{adr_key}#{}", section.slug);
280 let mut node = Node::new(key.clone(), NodeKind::AdrSection, section.title.clone())
281 .with_provenance(Provenance::Authored);
282 node.path = Some(self.path.clone());
283 if let Some(content) = stored(§ion.text) {
284 node.meta = serde_json::json!({ "content": content });
285 }
286 fs = fs.with_node(node).with_edge(Edge::authored(
287 adr_key.clone(),
288 key,
289 EdgeKind::Contains,
290 ));
291 }
292 fs
293 }
294}
295
296fn stored(text: &str) -> Option<serde_json::Value> {
306 let capped = rto_graph::cap_content(text);
307 (!capped.is_empty()).then(|| serde_json::Value::from(capped))
308}
309
310pub fn parse_adr(rel_path: &str, text: &str) -> Result<AdrDoc, ParseError> {
316 let (frontmatter, body) = split_frontmatter(text);
317 let body_offset = text.len() - body.len();
320 let body_line1 = text[..body_offset].lines().count() + 1;
321
322 let mut id = None;
323 let mut status = AdrStatus::Draft;
324 let mut fm_title = None;
325 let mut fm_version = None;
326 for line in frontmatter.lines() {
327 let line = line.trim();
328 if line.is_empty() || line.starts_with('#') {
329 continue;
330 }
331 let Some((key, value)) = line.split_once(':') else {
332 continue;
333 };
334 let value = clean_value(value);
335 match key.trim().to_ascii_lowercase().as_str() {
336 "adr-id" => id = Some(value.to_owned()),
337 "status" if !value.is_empty() => status = value.parse()?,
338 "title" => fm_title = Some(value.to_owned()),
339 "version" => fm_version = DocVersion::parse(value),
340 _ => {}
341 }
342 }
343 let id = id
344 .filter(|s| !s.is_empty())
345 .ok_or(ParseError::MissingAdrId)?;
346
347 let title = fm_title
348 .filter(|s| !s.is_empty())
349 .or_else(|| crate::text::first_h1(body))
350 .unwrap_or_else(|| format!("ADR-{id}"));
351
352 let scan = scan_body(&id, body, body_line1);
353
354 Ok(AdrDoc {
355 meta: AdrMeta {
356 id,
357 title,
358 status,
359 version: fm_version,
360 },
361 path: rel_path.to_owned(),
362 sections: scan.sections,
363 preamble: scan.preamble,
364 links: scan.links,
365 versions: scan.versions,
366 })
367}
368
369struct BodyScan {
376 preamble: String,
378 sections: Vec<Section>,
380 links: Vec<WikiLink>,
382 versions: VersionFacts,
384}
385
386fn scan_body(id: &str, body: &str, body_line1: usize) -> BodyScan {
396 let mut sections: Vec<Section> = Vec::new();
397 let mut links = Vec::new();
398 let mut versions = VersionFacts::default();
399 let mut current: Option<String> = None;
400 let mut in_fence = false;
401 let mut in_history = false;
402 let mut byte_offset = 0usize;
408 let mut span_start = 0usize;
409 let mut preamble_end: Option<usize> = None;
410 for (line_idx, line) in body.lines().enumerate() {
411 let line_start = byte_offset;
415 byte_offset += line.len();
416 if body[byte_offset..].starts_with("\r\n") {
417 byte_offset += 2;
418 } else if body[byte_offset..].starts_with('\n') {
419 byte_offset += 1;
420 }
421
422 if line.trim_start().starts_with("```") {
423 in_fence = !in_fence;
424 continue;
425 }
426 if in_fence {
427 continue;
428 }
429 if let Some(heading) = line.strip_prefix("## ") {
430 let title = heading.trim().to_owned();
431 in_history = is_version_history(&title);
432 let slug = crate::text::slugify(&title);
433 current = Some(slug.clone());
434 match sections.last_mut() {
436 Some(prev) => {
437 crate::text::trim_blank_lines(&body[span_start..line_start])
438 .clone_into(&mut prev.text);
439 }
440 None => preamble_end = Some(line_start),
441 }
442 span_start = byte_offset;
443 sections.push(Section {
444 slug,
445 title,
446 text: String::new(),
447 });
448 }
449 if in_history {
450 versions.history.extend(history_row_version(line));
451 } else {
452 versions.summary_row = versions.summary_row.or_else(|| summary_row_version(line));
453 let file_line = body_line1 + line_idx;
454 versions
455 .inline_refs
456 .extend(inline_version_refs(line).map(|version| InlineVersionRef {
457 line: file_line,
458 version,
459 }));
460 }
461 for raw in crate::text::scan_wiki_links(line) {
462 let from = match ¤t {
463 Some(slug) => format!("adr:{id}#{slug}"),
464 None => format!("adr:{id}"),
465 };
466 if let Some(target_key) = resolve_target(&raw) {
467 links.push(WikiLink {
468 from,
469 raw,
470 target_key,
471 });
472 }
473 }
474 }
475
476 if let Some(last) = sections.last_mut() {
479 crate::text::trim_blank_lines(&body[span_start..]).clone_into(&mut last.text);
480 }
481 let preamble =
482 crate::text::trim_blank_lines(&body[..preamble_end.unwrap_or(body.len())]).to_owned();
483
484 BodyScan {
485 preamble,
486 sections,
487 links,
488 versions,
489 }
490}
491
492fn is_version_history(title: &str) -> bool {
496 title.eq_ignore_ascii_case("Document version history")
497 || title.eq_ignore_ascii_case("Version history")
498}
499
500fn history_row_version(line: &str) -> Option<DocVersion> {
504 let rest = line.trim_start().strip_prefix('|')?;
505 let (first, _) = rest.split_once('|')?;
506 DocVersion::parse(first.trim())
507}
508
509fn summary_row_version(line: &str) -> Option<DocVersion> {
511 let mut cells = line.trim_start().strip_prefix('|')?.split('|');
512 if cells.next()?.trim() != "**Document version**" {
513 return None;
514 }
515 DocVersion::parse(cells.next()?.trim())
516}
517
518fn inline_version_refs(line: &str) -> impl Iterator<Item = DocVersion> + '_ {
526 const MARK: &str = "(Update, v";
527 line.match_indices(MARK)
528 .filter_map(|(i, _)| DocVersion::parse_prefix(&line[i + MARK.len()..]))
529}
530
531pub(crate) fn split_frontmatter(text: &str) -> (&str, &str) {
535 let Some(rest) = text.strip_prefix("---\n") else {
536 return ("", text);
537 };
538 match rest.find("\n---\n") {
539 Some(end) => (&rest[..end], &rest[end + 5..]),
540 None => match rest.strip_suffix("\n---") {
542 Some(fm) => (fm, ""),
543 None => ("", text),
544 },
545 }
546}
547
548pub(crate) fn clean_value(raw: &str) -> &str {
554 let raw = raw.trim();
555 if raw.starts_with('"') || raw.starts_with('\'') {
556 return strip_quotes(raw);
557 }
558 match raw.find(" #") {
559 Some(idx) => raw[..idx].trim_end(),
560 None => raw,
561 }
562}
563
564fn strip_quotes(s: &str) -> &str {
566 for q in ['"', '\''] {
567 if let Some(inner) = s.strip_prefix(q).and_then(|s| s.strip_suffix(q)) {
568 return inner;
569 }
570 }
571 s
572}
573
574pub(crate) fn resolve_target(raw: &str) -> Option<String> {
578 let (path, symbol) = match raw.split_once('#') {
579 Some((p, s)) => (p.trim(), Some(s.trim())),
580 None => (raw.trim(), None),
581 };
582 if path.is_empty() {
583 return None;
584 }
585 match symbol.filter(|s| !s.is_empty()) {
586 Some(symbol) => {
587 let lang = crate::text::lang_for(path);
588 Some(format!("sym:{lang}:{path}#{symbol}"))
589 }
590 None => Some(format!("file:{path}")),
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::{AdrStatus, parse_adr};
597 use crate::text::slugify;
598
599 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";
603
604 #[test]
609 fn a_section_carries_its_own_body_and_not_the_document() {
610 let doc = parse_adr("docs/adr/0015-spans.md", SPANS).expect("parse");
611 let by = |slug: &str| {
612 doc.sections
613 .iter()
614 .find(|s| s.slug == slug)
615 .unwrap_or_else(|| panic!("no section {slug}"))
616 };
617
618 let context = &by("context").text;
619 assert!(
620 context.contains("ALPHA the context prose."),
621 "the section keeps its own prose: {context:?}"
622 );
623 assert!(
624 !context.contains("BRAVO"),
625 "and not the next section's: {context:?}"
626 );
627
628 let consequences = &by("consequences").text;
629 assert!(
630 consequences.contains("BRAVO the consequences prose."),
631 "{consequences:?}"
632 );
633 assert!(
634 consequences.contains("### A subheading"),
635 "a `###` inside the span is body text, not a boundary: {consequences:?}"
636 );
637 assert!(
638 !consequences.contains("ALPHA"),
639 "and not the previous section's: {consequences:?}"
640 );
641
642 assert!(
644 !context.contains("## Consequences"),
645 "the boundary heading is excluded: {context:?}"
646 );
647 assert!(
648 !consequences.starts_with("## "),
649 "a section note is already titled by its heading: {consequences:?}"
650 );
651
652 assert_eq!(
655 doc.sections
656 .iter()
657 .map(|s| s.slug.as_str())
658 .collect::<Vec<_>>(),
659 ["context", "consequences", "example", "empty"],
660 "a fenced `## ` line does not open a section"
661 );
662 assert!(
663 context.contains("## Not A Heading"),
664 "the fenced line stays inside the section that encloses it: {context:?}"
665 );
666
667 assert_eq!(by("empty").text, "");
670 }
671
672 #[test]
676 fn the_preamble_is_the_span_that_belongs_to_no_section() {
677 let doc = parse_adr("docs/adr/0015-spans.md", SPANS).expect("parse");
678 assert!(
679 doc.preamble.contains("# ADR-0015: Spans"),
680 "{:?}",
681 doc.preamble
682 );
683 assert!(
684 doc.preamble.contains("| **State** | Accepted |"),
685 "the summary table is ADR-level, not section-level: {:?}",
686 doc.preamble
687 );
688 assert!(
689 !doc.preamble.contains("ALPHA") && !doc.preamble.contains("BRAVO"),
690 "no section body: {:?}",
691 doc.preamble
692 );
693 assert!(!doc.preamble.contains("adr-id"), "{:?}", doc.preamble);
695 }
696
697 #[test]
700 fn a_sectionless_adr_is_all_preamble() {
701 let doc = parse_adr(
702 "docs/adr/0099-x.md",
703 "---\nadr-id: \"0099\"\n---\n\n# ADR-0099\n\nJust prose.\n",
704 )
705 .expect("parse");
706 assert!(doc.sections.is_empty());
707 assert!(doc.preamble.contains("Just prose."), "{:?}", doc.preamble);
708 }
709
710 #[test]
714 fn facts_store_the_section_text_capped() {
715 let long = "x".repeat(4000);
716 let src = format!(
717 "---\nadr-id: \"0021\"\nstatus: Accepted\n---\n\n# ADR-0021\n\n## Context\n\n{long}\n"
718 );
719 let doc = parse_adr("docs/adr/0021-x.md", &src).expect("parse");
720 let facts = doc.facts();
721
722 let section = facts
723 .nodes
724 .iter()
725 .find(|n| n.key == "adr:0021#context")
726 .expect("section node");
727 let stored = section.meta["content"].as_str().expect("content");
728 assert_eq!(
729 stored.chars().count(),
730 1500,
731 "capped by the same budget the derived layer uses"
732 );
733 assert!(
734 doc.sections[0].text.chars().count() > stored.chars().count(),
735 "the parsed span itself stays whole — only the store is capped"
736 );
737
738 let adr = facts
740 .nodes
741 .iter()
742 .find(|n| n.key == "adr:0021")
743 .expect("adr node");
744 assert_eq!(adr.meta["status"], "Accepted");
745 assert!(
746 adr.meta["content"]
747 .as_str()
748 .expect("preamble")
749 .contains("ADR-0021"),
750 "{:?}",
751 adr.meta
752 );
753 assert!(
754 !adr.meta["content"]
755 .as_str()
756 .expect("preamble")
757 .contains("xxxx"),
758 "the ADR node does not restate its sections: {:?}",
759 adr.meta
760 );
761 }
762
763 #[test]
767 fn an_empty_section_stores_no_content_key() {
768 let doc = parse_adr("docs/adr/0015-spans.md", SPANS).expect("parse");
769 let facts = doc.facts();
770 let empty = facts
771 .nodes
772 .iter()
773 .find(|n| n.key == "adr:0015#empty")
774 .expect("node");
775 assert!(empty.meta.get("content").is_none(), "{:?}", empty.meta);
776 }
777
778 #[test]
780 fn text_for_key_maps_a_key_back_to_its_span() {
781 let doc = parse_adr("docs/adr/0015-spans.md", SPANS).expect("parse");
782
783 assert!(
784 doc.text_for_key("adr:0015")
785 .expect("preamble")
786 .contains("# ADR-0015: Spans")
787 );
788 assert!(
789 doc.text_for_key("adr:0015#consequences")
790 .expect("section")
791 .contains("BRAVO the consequences prose.")
792 );
793 assert!(
794 !doc.text_for_key("adr:0015#consequences")
795 .expect("section")
796 .contains("ALPHA"),
797 "a section key never resolves to the document"
798 );
799
800 assert_eq!(doc.text_for_key("adr:0015#empty"), None);
803 assert_eq!(doc.text_for_key("adr:0015#nosuch"), None);
805 assert_eq!(doc.text_for_key("adr:0016#context"), None);
806 assert_eq!(doc.text_for_key("file:docs/adr/0015-spans.md"), None);
807 assert_eq!(doc.text_for_key("adr:00151"), None);
810 }
811
812 #[test]
819 fn a_span_keeps_the_indentation_of_its_first_content_line() {
820 let doc = parse_adr("docs/adr/0015-spans.md", SPANS).expect("parse");
821 let example = doc
822 .sections
823 .iter()
824 .find(|s| s.slug == "example")
825 .expect("no section example");
826
827 assert_eq!(
830 example.text,
831 " CHARLIE indented code;\n\nCHARLIE prose. "
832 );
833 assert_eq!(
835 doc.text_for_key("adr:0015#example"),
836 Some(" CHARLIE indented code;\n\nCHARLIE prose. ")
837 );
838 }
839
840 #[test]
843 fn the_preamble_keeps_the_indentation_of_its_first_content_line() {
844 let doc = parse_adr(
845 "docs/adr/0016-indented.md",
846 "---\nadr-id: \"0016\"\nstatus: Draft\n---\n\n DELTA indented preamble;\n\n## Context\n\nprose.\n",
847 )
848 .expect("parse");
849 assert_eq!(doc.preamble, " DELTA indented preamble;");
850 }
851
852 #[test]
853 fn parses_all_house_statuses() {
854 for (s, want) in [
855 ("Draft", AdrStatus::Draft),
856 ("For Review", AdrStatus::ForReview),
857 ("Accepted", AdrStatus::Accepted),
858 ("Rejected", AdrStatus::Rejected),
859 ("Superseded", AdrStatus::Superseded),
860 ] {
861 assert_eq!(s.parse::<AdrStatus>().expect("parse"), want);
862 }
863 }
864
865 #[test]
866 fn rejects_unknown_status() {
867 assert!("Pending".parse::<AdrStatus>().is_err());
868 }
869
870 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";
871
872 #[test]
873 fn parses_frontmatter_sections_and_links() {
874 let doc = parse_adr("docs/adr/0007-example.md", ADR).expect("parse");
875 assert_eq!(doc.meta.id, "0007");
876 assert_eq!(doc.meta.title, "Example decision");
877 assert_eq!(doc.meta.status, AdrStatus::Accepted);
878
879 let slugs: Vec<_> = doc.sections.iter().map(|s| s.slug.as_str()).collect();
880 assert_eq!(slugs, ["context", "decision"]);
881
882 assert_eq!(doc.links.len(), 2);
884 assert_eq!(doc.links[0].from, "adr:0007#context");
885 assert_eq!(
886 doc.links[0].target_key,
887 "sym:rust:crates/rto-graph/src/store.rs#Store"
888 );
889 assert_eq!(doc.links[1].from, "adr:0007#decision");
890 assert_eq!(doc.links[1].target_key, "file:docs/adr/0001-x.md");
891 }
892
893 #[test]
894 fn adr_facts_carry_status_and_sections() {
895 let doc = parse_adr("docs/adr/0007-example.md", ADR).expect("parse");
896 let fs = doc.facts();
897 assert!(fs.nodes.iter().any(|n| n.key == "adr:0007"));
898 assert!(fs.nodes.iter().any(|n| n.key == "adr:0007#context"));
899 let adr = fs
900 .nodes
901 .iter()
902 .find(|n| n.key == "adr:0007")
903 .expect("adr node");
904 assert_eq!(adr.meta["status"], "Accepted");
905 assert!(
908 fs.nodes
909 .iter()
910 .all(|n| n.provenance == rto_graph::Provenance::Authored),
911 "ADR nodes must be Authored"
912 );
913 assert_eq!(fs.edges.iter().filter(|e| e.src == "adr:0007").count(), 2);
915 }
916
917 #[test]
918 fn missing_adr_id_is_an_error() {
919 let text = "---\nTitle: No id\nstatus: Draft\n---\n\n# Body\n";
920 assert_eq!(
921 parse_adr("x.md", text),
922 Err(super::ParseError::MissingAdrId)
923 );
924 }
925
926 #[test]
927 fn slugify_collapses_punctuation() {
928 assert_eq!(
929 slugify("Options considered + consequences"),
930 "options-considered-consequences"
931 );
932 assert_eq!(slugify(" Reference "), "reference");
933 }
934
935 #[test]
936 fn an_adr_title_falling_back_to_its_h1_carries_no_markup() {
937 let adr = parse_adr(
940 "docs/adr/0021-x.md",
941 "---\nadr-id: 0021\nstatus: Accepted\n---\n\n# Sandboxed *linting* {#lint}\n",
942 )
943 .expect("parse");
944 assert_eq!(adr.meta.title, "Sandboxed linting");
945 }
946}