Skip to main content

rto_spec/
blueprint.rs

1//! House-style **blueprint** (technical implementation plan) parsing.
2//!
3//! Blueprints are the ADR's sibling in the authoring pillar (ADR-0004): a
4//! graph-grounded build plan rather than a decision record. Unlike ADRs they
5//! carry **no YAML frontmatter** — a blueprint is identified by its house-style
6//! H1 (`… — Technical Implementation Plan`) or by living under `docs/blueprint`.
7//! Structurally they mirror ADRs: `## ` headings become sections and
8//! `[[path#Symbol]]` wiki-links become the *authored* layer over code, validated
9//! against the derived graph by [`crate::check`] exactly like ADR links.
10//!
11//! Keys are path-based (`blueprint:<path>`), since a blueprint has no numeric id.
12
13use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
14
15use crate::adr::{Section, WikiLink, resolve_target};
16use crate::text::first_h1;
17
18/// The house-style marker in a blueprint's H1 (from `roteiro spec … blueprint`),
19/// including the leading em dash so a doc whose H1 merely mentions the phrase in
20/// prose is not misclassified — detection matches the scaffold's `… —
21/// Technical Implementation Plan` exactly.
22const MARKER: &str = "— Technical Implementation Plan";
23
24/// A fully-parsed blueprint: title, section structure, and authored links.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct BlueprintDoc {
27    /// Repository-relative path of the blueprint file.
28    pub path: String,
29    /// Title, from the first `# ` heading (or the file stem).
30    pub title: String,
31    /// `## ` sections in document order.
32    pub sections: Vec<Section>,
33    /// Authored `[[…]]` links in document order.
34    pub links: Vec<WikiLink>,
35}
36
37impl BlueprintDoc {
38    /// The natural key of this blueprint's node (`blueprint:<path>`).
39    #[must_use]
40    pub fn key(&self) -> String {
41        format!("blueprint:{}", self.path)
42    }
43
44    /// The authored nodes and structural edges: a `blueprint` node, one
45    /// `blueprint_section` node per section, and `contains` edges between them.
46    /// Wiki-links are *not* included — [`crate::check`] validates them against the
47    /// code graph before they become edges.
48    #[must_use]
49    pub fn facts(&self) -> FactSet {
50        let key = self.key();
51        let mut node = Node::new(
52            key.clone(),
53            NodeKind::Other("blueprint".into()),
54            self.title.clone(),
55        )
56        .with_provenance(Provenance::Authored);
57        node.path = Some(self.path.clone());
58        let mut fs = FactSet::new().with_node(node);
59
60        for section in &self.sections {
61            let skey = format!("{key}#{}", section.slug);
62            let mut snode = Node::new(
63                skey.clone(),
64                NodeKind::Other("blueprint_section".into()),
65                section.title.clone(),
66            )
67            .with_provenance(Provenance::Authored);
68            snode.path = Some(self.path.clone());
69            fs = fs.with_node(snode).with_edge(Edge::authored(
70                key.clone(),
71                skey,
72                EdgeKind::Contains,
73            ));
74        }
75        fs
76    }
77}
78
79/// Whether a markdown file is a house-style blueprint: it lives under
80/// `docs/blueprint`/`docs/blueprints`, or its first H1 carries the
81/// `— Technical Implementation Plan` marker. Callers apply this only to non-ADR
82/// markdown (ADRs are recognised first).
83#[must_use]
84pub fn is_blueprint(rel_path: &str, text: &str) -> bool {
85    let lower = rel_path.to_ascii_lowercase();
86    lower.starts_with("docs/blueprint") || first_h1(text).is_some_and(|h| h.contains(MARKER))
87}
88
89/// Parse a house-style blueprint markdown document at `rel_path`. Infallible:
90/// with no frontmatter there is nothing that can fail to parse (an empty or
91/// heading-less file yields a titled node with no sections/links).
92#[must_use]
93pub fn parse_blueprint(rel_path: &str, text: &str) -> BlueprintDoc {
94    let key = format!("blueprint:{rel_path}");
95    let title = first_h1(text).unwrap_or_else(|| stem_of(rel_path));
96
97    // Walk the body, tracking the current section so links are attributed to it.
98    // Fenced code blocks are skipped so documented `[[…]]` examples are not
99    // mistaken for real authored links (as in ADR parsing).
100    let mut sections = Vec::new();
101    let mut links = Vec::new();
102    let mut current: Option<String> = None;
103    let mut in_fence = false;
104    for line in text.lines() {
105        if line.trim_start().starts_with("```") {
106            in_fence = !in_fence;
107            continue;
108        }
109        if in_fence {
110            continue;
111        }
112        if let Some(heading) = line.strip_prefix("## ") {
113            let title = heading.trim().to_owned();
114            let slug = crate::text::slugify(&title);
115            current = Some(slug.clone());
116            // No `text`: `Section` is shared with `parse_adr`, and only that
117            // parser populates it so far. A blueprint section note is empty in
118            // the vault for the same reason an ADR's was (#545) — the fix is the
119            // same shape and is deliberately not in that PR's scope, which is
120            // ADRs. 11 notes here against 199 there.
121            sections.push(Section {
122                slug,
123                title,
124                text: String::new(),
125            });
126        }
127        for raw in crate::text::scan_wiki_links(line) {
128            let from = match &current {
129                Some(slug) => format!("{key}#{slug}"),
130                None => key.clone(),
131            };
132            if let Some(target_key) = resolve_target(&raw) {
133                links.push(WikiLink {
134                    from,
135                    raw,
136                    target_key,
137                });
138            }
139        }
140    }
141
142    BlueprintDoc {
143        path: rel_path.to_owned(),
144        title,
145        sections,
146        links,
147    }
148}
149
150/// The file stem (basename without extension) of a path.
151fn stem_of(path: &str) -> String {
152    let name = path.rsplit('/').next().unwrap_or(path);
153    name.rsplit_once('.')
154        .map_or(name, |(stem, _)| stem)
155        .to_owned()
156}
157
158#[cfg(test)]
159mod tests {
160    use super::{is_blueprint, parse_blueprint};
161    use rto_graph::{EdgeKind, NodeKind};
162
163    const BP: &str = "# Token flow — Technical Implementation Plan\n\n\
164                      Grounded in: [[docs/adr/0004-x.md]].\n\n\
165                      > **Status.** Design → build.\n\n\
166                      ## 1. Crate placement\n\n\
167                      Touches [[crates/rto-graph/src/store.rs#Store]].\n\n\
168                      ## 2. Design\n\n\
169                      ```\n[[not/a/real#Link]]\n```\n\nDone.\n";
170
171    #[test]
172    fn detects_blueprints_by_marker_or_path() {
173        assert!(is_blueprint("docs/plans/token.md", BP), "H1 marker");
174        assert!(
175            is_blueprint("docs/blueprint/anything.md", "# Plain\n"),
176            "path prefix"
177        );
178        assert!(
179            !is_blueprint("docs/notes/x.md", "# Just a note\n"),
180            "neither marker nor path"
181        );
182        // The phrase alone (no leading em dash) does not qualify — the marker is
183        // anchored to the scaffold's `… — Technical Implementation Plan` H1.
184        assert!(
185            !is_blueprint(
186                "docs/notes/y.md",
187                "# Our Technical Implementation Plan overview\n"
188            ),
189            "phrase without the em-dash marker is not a blueprint"
190        );
191    }
192
193    #[test]
194    fn detection_reads_the_h1_the_parser_sees_not_the_line() {
195        // `is_blueprint` is a *predicate*, not a title, so reading the H1 through
196        // the parser changes it — in four ways, each of which is a fix. The
197        // marker itself survives an attribute block untouched: an em dash and
198        // spaces cannot appear inside a parsed `{#id}`.
199        assert!(
200            is_blueprint(
201                "docs/plans/a.md",
202                "# Token flow — Technical Implementation Plan {#plan}\n"
203            ),
204            "an attribute block must not hide the marker"
205        );
206        assert!(
207            is_blueprint(
208                "docs/plans/b.md",
209                "# Token flow — Technical *Implementation* Plan\n"
210            ),
211            "emphasis is markup; the marker is still in the text a reader sees"
212        );
213        assert!(
214            is_blueprint(
215                "docs/plans/c.md",
216                "Token flow — Technical Implementation Plan\n===\n"
217            ),
218            "a setext heading is an H1"
219        );
220        // The two that narrow: a fenced `#` is a code sample. A document *about*
221        // the blueprint scaffold quoting its H1 is not itself a blueprint.
222        assert!(
223            !is_blueprint(
224                "docs/notes/d.md",
225                "# How to write one\n\n```\n# Widget — Technical Implementation Plan\n```\n"
226            ),
227            "a fenced example must not classify the document that quotes it"
228        );
229        assert!(
230            !is_blueprint(
231                "docs/notes/e.md",
232                "```\n# Widget — Technical Implementation Plan\n```\n"
233            ),
234            "a fenced `#` is not a heading at all"
235        );
236    }
237
238    #[test]
239    fn a_blueprint_title_falling_back_to_its_h1_carries_no_markup() {
240        let bp = parse_blueprint(
241            "docs/plans/token.md",
242            "# Token flow — Technical Implementation Plan {#plan}\n",
243        );
244        assert_eq!(bp.title, "Token flow — Technical Implementation Plan");
245        assert!(!bp.title.contains("{#"));
246    }
247
248    #[test]
249    fn parses_title_sections_and_links() {
250        let bp = parse_blueprint("docs/plans/token.md", BP);
251        assert_eq!(bp.key(), "blueprint:docs/plans/token.md");
252        assert_eq!(bp.title, "Token flow — Technical Implementation Plan");
253
254        // Numbered house-style headings keep the number in the slug.
255        let slugs: Vec<_> = bp.sections.iter().map(|s| s.slug.as_str()).collect();
256        assert_eq!(slugs, ["1-crate-placement", "2-design"]);
257
258        // Two resolvable links; the fenced `[[not/a/real#Link]]` is ignored.
259        assert_eq!(bp.links.len(), 2);
260        assert_eq!(bp.links[0].from, "blueprint:docs/plans/token.md");
261        assert_eq!(bp.links[0].target_key, "file:docs/adr/0004-x.md");
262        assert_eq!(
263            bp.links[1].from,
264            "blueprint:docs/plans/token.md#1-crate-placement"
265        );
266        assert_eq!(
267            bp.links[1].target_key,
268            "sym:rust:crates/rto-graph/src/store.rs#Store"
269        );
270    }
271
272    #[test]
273    fn facts_carry_blueprint_and_section_nodes() {
274        let bp = parse_blueprint("docs/plans/token.md", BP);
275        let fs = bp.facts();
276        let bp_node = fs
277            .nodes
278            .iter()
279            .find(|n| n.key == "blueprint:docs/plans/token.md")
280            .expect("blueprint node");
281        assert_eq!(bp_node.kind, NodeKind::Other("blueprint".into()));
282        assert!(
283            fs.nodes
284                .iter()
285                .any(|n| n.kind == NodeKind::Other("blueprint_section".into())
286                    && n.key.ends_with("#2-design"))
287        );
288        // The blueprint contains its two sections.
289        assert_eq!(
290            fs.edges
291                .iter()
292                .filter(|e| e.kind == EdgeKind::Contains
293                    && e.src == "blueprint:docs/plans/token.md")
294                .count(),
295            2
296        );
297    }
298}