1use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Provenance};
14
15use crate::adr::{Section, WikiLink, resolve_target};
16use crate::text::first_h1;
17
18const MARKER: &str = "— Technical Implementation Plan";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct BlueprintDoc {
27 pub path: String,
29 pub title: String,
31 pub sections: Vec<Section>,
33 pub links: Vec<WikiLink>,
35}
36
37impl BlueprintDoc {
38 #[must_use]
40 pub fn key(&self) -> String {
41 format!("blueprint:{}", self.path)
42 }
43
44 #[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#[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#[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 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 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 ¤t {
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
150fn 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 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 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 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 let slugs: Vec<_> = bp.sections.iter().map(|s| s.slug.as_str()).collect();
256 assert_eq!(slugs, ["1-crate-placement", "2-design"]);
257
258 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 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}