Skip to main content

rto_spec/
adr.rs

1//! House-style ADR parsing: frontmatter metadata, section structure, and the
2//! `[[path#Symbol]]` wiki-links that form the *authored* layer over code.
3//!
4//! The frontmatter is hand-parsed rather than run through a YAML crate: it is a
5//! flat `key: value` block that also contains `#` comment lines (which a strict
6//! YAML parser handles differently), and hand-parsing keeps `rto-spec`
7//! dependency-free (no `serde_yaml`, which is unmaintained and would trip the
8//! audit gate). See ADR-0001 / `docs/BUILD_PLAN.md` Q4.
9
10use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
11use serde::{Deserialize, Serialize};
12
13/// ADR lifecycle states, exactly as the house style defines them.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15pub enum AdrStatus {
16    /// Being drafted.
17    Draft,
18    /// Circulated for advisory review.
19    ForReview,
20    /// Decision accepted.
21    Accepted,
22    /// Decision rejected.
23    Rejected,
24    /// Replaced by a later ADR.
25    Superseded,
26}
27
28impl AdrStatus {
29    /// The canonical house-style label for this status.
30    #[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    /// Whether an ADR in this state is a valid target for a `@rto:` annotation
42    /// (i.e. still authoritative — not rejected or superseded).
43    #[must_use]
44    pub fn is_active(self) -> bool {
45        matches!(self, Self::Draft | Self::ForReview | Self::Accepted)
46    }
47}
48
49/// Errors raised while parsing ADR metadata.
50#[derive(Debug, thiserror::Error, PartialEq, Eq)]
51pub enum ParseError {
52    /// The status string is not one of the five house-style states.
53    #[error("unknown ADR status: {0}")]
54    UnknownStatus(String),
55    /// The frontmatter lacks the required `adr-id` field.
56    #[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/// A two-component ADR **document** version, e.g. `1.10`.
76///
77/// Compared component-wise rather than lexically or as a decimal, because
78/// both of those readings sort `1.10` *below* `1.9` — and this repository has
79/// an ADR that reached 1.11 one row at a time.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
81pub struct DocVersion {
82    /// The part before the dot.
83    pub major: u32,
84    /// The part after it. `10` is a later revision than `9`, not an earlier one.
85    pub minor: u32,
86}
87
88impl DocVersion {
89    /// Parse a string that is *exactly* `X.Y`.
90    ///
91    /// A third component makes this `None`: `1.13.0` is a crate release, and
92    /// reading its first two parts as a document version is the mistake this
93    /// function exists to refuse.
94    #[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    /// Parse a leading `X.Y` from `s`, ignoring whatever follows — but still
108    /// refusing a third `.N` component, for the reason [`Self::parse`] gives.
109    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/// One `(Update, vX.Y…)` note found in an ADR body.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct InlineVersionRef {
139    /// 1-based line number **in the file**, frontmatter included.
140    pub line: usize,
141    /// The document version the note names.
142    pub version: DocVersion,
143}
144
145/// Every claim an ADR makes about its own version, gathered so
146/// [`crate::check::validate`] can cross-check them against each other.
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct VersionFacts {
149    /// The version cell of the `| **Document version** | X.Y |` summary row.
150    pub summary_row: Option<DocVersion>,
151    /// The first cell of each version-history row, in document order.
152    pub history: Vec<DocVersion>,
153    /// `(Update, vX.Y…)` notes in the body, **excluding** the version-history
154    /// section — a history row that describes removing such a note quotes it,
155    /// and quoting it is not making the claim again.
156    pub inline_refs: Vec<InlineVersionRef>,
157}
158
159/// Metadata for one ADR, as read from its frontmatter.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct AdrMeta {
162    /// Zero-padded ADR id, e.g. `0001`.
163    pub id: String,
164    /// Current title (evolves with the decision).
165    pub title: String,
166    /// Lifecycle state.
167    pub status: AdrStatus,
168    /// The `version:` frontmatter field, when it parses as `X.Y`.
169    pub version: Option<DocVersion>,
170}
171
172/// One `## ` section of an ADR body.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct Section {
175    /// URL-safe slug derived from the heading.
176    pub slug: String,
177    /// Heading text.
178    pub title: String,
179    /// The section's body: everything between this `## ` heading and the next
180    /// one, verbatim and **uncapped**, with surrounding blank lines trimmed.
181    ///
182    /// The heading line itself is excluded — a section's note is already titled
183    /// by it, and a `### ` subheading inside the span is body text and stays.
184    /// [`AdrDoc::facts`] caps this before it reaches the store; the vault renders
185    /// it whole. Empty when a heading is immediately followed by another.
186    ///
187    /// Populated by [`parse_adr`] only. [`crate::blueprint`] and [`crate::site`]
188    /// share this struct and leave it empty — their section notes have the same
189    /// defect #545 fixes here, and fixing them is the same shape of change on a
190    /// different document class.
191    pub text: String,
192}
193
194/// A `[[path#Symbol]]` (or `[[path]]`) authored link found in an ADR, resolved
195/// to the graph node key it should point at.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct WikiLink {
198    /// Node key of the ADR or section the link appears in.
199    pub from: String,
200    /// The raw link text between the brackets.
201    pub raw: String,
202    /// The graph node key the link targets.
203    pub target_key: String,
204}
205
206/// A fully-parsed ADR: metadata, section structure, and authored links.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct AdrDoc {
209    /// Frontmatter metadata.
210    pub meta: AdrMeta,
211    /// Repository-relative path of the ADR file.
212    pub path: String,
213    /// `## ` sections in document order.
214    pub sections: Vec<Section>,
215    /// The body text *before* the first `## ` heading — in house style, the `# `
216    /// title and the summary table — verbatim and uncapped, with surrounding
217    /// blank lines trimmed (the same rule the sections use, see [`Section::text`]).
218    ///
219    /// This is the only part of an ADR that belongs to no section, which is
220    /// exactly why the `adr` node carries it and not the whole document: the
221    /// sections already hold the body between them, so nothing is stored twice.
222    /// The whole document, for a reader who wants it, is on the `file:` node.
223    pub preamble: String,
224    /// Authored `[[…]]` links in document order.
225    pub links: Vec<WikiLink>,
226    /// What the document says about its own version, in three places.
227    pub versions: VersionFacts,
228}
229
230impl AdrDoc {
231    /// The natural key of this ADR's node (`adr:<id>`).
232    #[must_use]
233    pub fn key(&self) -> String {
234        format!("adr:{}", self.meta.id)
235    }
236
237    /// The **full, uncapped** text the node `key` should show, or `None` if `key`
238    /// names no part of this ADR (or names an empty one).
239    ///
240    /// The inverse of the key grammar [`Self::key`] and [`Self::facts`] build, and
241    /// deliberately their neighbour: a renderer that re-split `adr:0015#consequences`
242    /// with its own rule would be reimplementing the thing it is trying to read.
243    ///
244    /// The split is the point. `adr:0015` gets its preamble and a section gets its
245    /// own span — never the whole document, which is what a path-only rule would
246    /// hand to all twenty ADR notes and all 179 section notes alike, beside the
247    /// `file:` note that already carries it once.
248    #[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            // `adr:00151` also strips the `adr:0015` prefix; requiring the `#` is
255            // what refuses it rather than reading `1` as a slug.
256            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    /// The authored nodes and structural edges for this ADR: an `adr` node, one
263    /// `adr_section` node per section, and `contains` edges between them. Wiki
264    /// links are *not* included — they are validated against the code graph by
265    /// [`crate::check`] before becoming edges.
266    #[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(&section.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
296/// The `meta.content` value for one span of an ADR, or `None` when the span is
297/// empty (a heading immediately followed by another) — an empty string would be a
298/// key that says nothing, and `search`/`duplicates` both gate on content being
299/// non-empty.
300///
301/// Capped by [`rto_graph::cap_content`] rather than by a bound of this module's
302/// own: the store is exportable and ships with the graph, so authored text lands
303/// in it under the same budget the derived layer uses. The vault does not read
304/// this — it renders the uncapped text from the blob (see [`AdrDoc::text_for_key`]).
305fn 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
310/// Parse an ADR markdown document at `rel_path`.
311///
312/// # Errors
313/// Returns [`ParseError::MissingAdrId`] if the frontmatter has no `adr-id`, or
314/// [`ParseError::UnknownStatus`] if the `status` value is not a house state.
315pub fn parse_adr(rel_path: &str, text: &str) -> Result<AdrDoc, ParseError> {
316    let (frontmatter, body) = split_frontmatter(text);
317    // Violations point at the file, so the frontmatter just consumed has to be
318    // counted back in: `body` is a suffix of `text`, so its offset is the split.
319    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
369/// Everything one pass over an ADR body yields.
370///
371/// Grouped into a struct rather than returned as a tuple of four because three of
372/// the four are only meaningful together: a `## ` heading simultaneously ends one
373/// span, opens the next, decides whether the rows that follow are version history,
374/// and re-attributes every `[[…]]` link after it.
375struct BodyScan {
376    /// Body text before the first `## `.
377    preamble: String,
378    /// `## ` sections in document order, each carrying its own span.
379    sections: Vec<Section>,
380    /// Authored `[[…]]` links, attributed to the section they appear in.
381    links: Vec<WikiLink>,
382    /// What the document says about its own version, in three places.
383    versions: VersionFacts,
384}
385
386/// Walk `body` once, tracking the current section so links are attributed to it
387/// and each `## ` span can be sliced back out.
388///
389/// Fenced code blocks are skipped so documented examples of `[[…]]` syntax are not
390/// mistaken for real authored links — and, for the same reason, a `## ` line inside
391/// a fence is body text rather than a section boundary.
392///
393/// `body_line1` is the 1-based line number `body` starts at in the file, so a
394/// violation can point at the file rather than at the post-frontmatter offset.
395fn 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    // Byte offsets into `body`, so each `## ` span can be sliced back out of it:
403    // `span_start` is where the open span's text begins (just past its heading
404    // line), and `preamble_end` is fixed by the first heading. Tracked here rather
405    // than re-derived by a second pass, because this loop already knows where
406    // every heading is and which of them are inside a code fence.
407    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        // Advance the cursor first: every `continue` below still consumes a line,
412        // and a fenced line's bytes belong to whichever section encloses it.
413        // `str::lines` strips a `\r\n` terminator as well as a `\n`.
414        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            // Close the span this heading ends — the preamble if it is the first.
435            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 &current {
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    // Close the last open span at end of document; with no `## ` at all the whole
477    // body is preamble.
478    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
492/// Whether a `## ` heading opens the version-history table. Both spellings are
493/// in use across this repository's own ADRs, so both are recognised rather than
494/// one being declared canonical by a parser.
495fn 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
500/// The version in a table row's first cell, when that cell holds a version and
501/// nothing else. The header (`| Version | …`) and separator (`|---|…`) rows fail
502/// to parse, which is exactly how they are skipped.
503fn 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
509/// The version in the `| **Document version** | X.Y |` row of the summary table.
510fn 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
518/// Every `(Update, vX.Y…)` note on one line.
519///
520/// Anchored on the literal marker rather than on a bare `vX.Y`, because ADR
521/// bodies are full of *software* versions — `v1.13.0` for a crate release,
522/// `v0.9.7` for boxlite — and a loose scan reads their leading components as a
523/// document version. On this repository's 20 ADRs the marker occurs 4 times and
524/// a bare `vX.Y` scan occurs over 40, nearly all of them releases.
525fn 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
531/// Split leading `---`-delimited frontmatter from the body. Returns
532/// `("", text)` when there is no frontmatter. Shared with [`crate::site`],
533/// whose publication marker is a frontmatter field read the same way.
534pub(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        // A closing fence with no trailing newline (end of file).
541        None => match rest.strip_suffix("\n---") {
542            Some(fm) => (fm, ""),
543            None => ("", text),
544        },
545    }
546}
547
548/// Clean a raw frontmatter value: trim, drop a trailing ` #…` inline comment
549/// (YAML-style) from unquoted values, then strip surrounding quotes. Quoted
550/// values are left intact so a `#` inside quotes survives. Shared with
551/// [`crate::site`] so a site page's frontmatter is read by the same rules as an
552/// ADR's — a quoted slug, or a trailing comment, must not mean two things.
553pub(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
564/// Strip a single pair of surrounding single or double quotes.
565fn 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
574/// Resolve a wiki-link's inner text to a graph node key: `path#Symbol` →
575/// `sym:<lang>:<path>#<Symbol>`, or `path` → `file:<path>`. Shared with
576/// [`crate::blueprint`], whose links resolve the same way.
577pub(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    /// An ADR with a preamble, sections of clearly distinguishable prose, a
600    /// `## `-looking line inside a code fence, a section whose span begins on an
601    /// indented code block, and an empty trailing section.
602    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    /// The whole defect and the whole trap in one test: each section note gets
605    /// **its own** span and never the document. A path-only rule — the shape the
606    /// `prose_blob_oid` kind check in #544 exists to refuse — would put every one
607    /// of these strings in every one of these nodes.
608    #[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        // A heading line ends the span before it and does not open the next one.
643        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        // A `## ` inside a fence is body text of the section that encloses it —
653        // the same rule the link scanner already applies to `[[…]]`.
654        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        // A heading immediately followed by end-of-document has no text at all,
668        // and `stored` turns that into no `content` key rather than an empty one.
669        assert_eq!(by("empty").text, "");
670    }
671
672    /// The `adr:NNNN` node's share: the span belonging to no section. Storing the
673    /// whole document here instead would hold every section's text twice — once on
674    /// the ADR node and once on the section that owns it.
675    #[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        // Frontmatter is not body text and never reaches a note.
694        assert!(!doc.preamble.contains("adr-id"), "{:?}", doc.preamble);
695    }
696
697    /// A document with no `## ` at all is all preamble — the loop must close the
698    /// open span at end-of-document rather than dropping it.
699    #[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    /// The store half. The text reaches `meta.content` **capped**, because the
711    /// store is exportable and ships with the graph; the vault reads the uncapped
712    /// text from the blob instead.
713    #[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        // The ADR node keeps its status and gains its preamble.
739        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    /// An empty span stores no `content` key at all. `search` and
764    /// `infer::duplicates` both gate on content being non-empty, so an empty
765    /// string would be a key that says nothing while claiming to say something.
766    #[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    /// The render half's seam: a node key maps back to exactly the span it names.
779    #[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        // An empty span is `None`, so the note falls back rather than rendering a
801        // blank `## Content`.
802        assert_eq!(doc.text_for_key("adr:0015#empty"), None);
803        // Keys that are not this ADR's.
804        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        // `adr:00151` shares the `adr:0015` prefix; requiring the `#` refuses it
808        // rather than reading `1` as a slug.
809        assert_eq!(doc.text_for_key("adr:00151"), None);
810    }
811
812    /// A span is trimmed of surrounding *blank lines* and nothing else. `str::trim`
813    /// also eats the first content line's indentation, which in Markdown is
814    /// meaning: a section opening on an indented code block was stored — and
815    /// rendered into the vault note — as ordinary prose. Latent in this repository
816    /// (no ADR currently opens a section indented), but on the exact surface #545
817    /// exists to fix, since `text_for_key` hands this same string to the renderer.
818    #[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        // Exact, not `contains`: the leading four spaces are the whole point, and
828        // the two trailing spaces are a hard break rather than padding.
829        assert_eq!(
830            example.text,
831            "    CHARLIE indented code;\n\nCHARLIE prose.  "
832        );
833        // The renderer reads the same string through the key seam.
834        assert_eq!(
835            doc.text_for_key("adr:0015#example"),
836            Some("    CHARLIE indented code;\n\nCHARLIE prose.  ")
837        );
838    }
839
840    /// The preamble is sliced by the same rule — the third span close, which the
841    /// two section closes are easy to fix without.
842    #[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        // Two resolvable links (the empty `[[]]` is ignored).
883        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        // Every ADR node is tagged as the authored layer (so a derived-only sync
906        // leaves them alone).
907        assert!(
908            fs.nodes
909                .iter()
910                .all(|n| n.provenance == rto_graph::Provenance::Authored),
911            "ADR nodes must be Authored"
912        );
913        // adr contains its sections.
914        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        // `title:` in frontmatter wins; without it the H1 is the ADR node's
938        // title, and an anchored or emphasised H1 must still name the decision.
939        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}