1use std::collections::BTreeSet;
15
16use rto_graph::{NodeSummary, Store, StoreError, explain, search};
17
18pub const SPEC_SCHEMA: &str = "roteiro.spec/v1";
20
21#[derive(Debug, Clone, PartialEq, serde::Serialize)]
25pub struct SymbolContext {
26 pub node: NodeSummary,
28 pub container: Option<String>,
30 pub calls: Vec<String>,
32 pub called_by: Vec<String>,
34 pub authored_by: Vec<String>,
36}
37
38#[derive(Debug, Clone, PartialEq, serde::Serialize)]
41pub struct SpecContext {
42 pub schema: &'static str,
44 pub topic: String,
46 pub symbols: Vec<SymbolContext>,
48 pub docs: Vec<NodeSummary>,
50 pub related_adrs: Vec<String>,
52}
53
54const SYMBOL_KINDS: &[&str] = &["fn", "struct", "enum", "trait", "module"];
56const DOC_KINDS: &[&str] = &["adr", "adr_section", "blueprint", "doc", "lat_section"];
58
59pub fn context(store: &Store, topic: &str, limit: usize) -> Result<SpecContext, StoreError> {
65 if limit == 0 {
66 return Ok(SpecContext {
67 schema: SPEC_SCHEMA,
68 topic: topic.to_owned(),
69 symbols: Vec::new(),
70 docs: Vec::new(),
71 related_adrs: Vec::new(),
72 });
73 }
74 let hits = search(store, topic, limit.saturating_mul(3).max(30))?;
76
77 let mut symbols = Vec::new();
78 let mut docs = Vec::new();
79 let mut related_adrs: BTreeSet<String> = BTreeSet::new();
80
81 for hit in hits {
82 let kind = hit.node.kind.as_str();
83 if SYMBOL_KINDS.contains(&kind) {
84 if symbols.len() >= limit {
85 continue;
86 }
87 let Some(ex) = explain(store, &hit.node.key)? else {
88 continue;
89 };
90 let container = ex
91 .incoming
92 .iter()
93 .find(|e| e.kind == "contains" || e.kind == "defines")
94 .map(|e| e.node.clone());
95 let calls = edges_of(&ex.outgoing, "calls");
96 let called_by = edges_of(&ex.incoming, "calls");
97 let authored_by: Vec<String> = ex
98 .incoming
99 .iter()
100 .filter(|e| e.provenance == "authored")
101 .map(|e| e.node.clone())
102 .collect();
103 related_adrs.extend(authored_by.iter().cloned());
104 symbols.push(SymbolContext {
105 node: hit.node,
106 container,
107 calls,
108 called_by,
109 authored_by,
110 });
111 } else if DOC_KINDS.contains(&kind) {
112 if kind == "adr" || kind == "adr_section" {
113 related_adrs.insert(hit.node.key.clone());
114 }
115 if docs.len() < limit {
116 docs.push(hit.node);
117 }
118 }
119 }
120
121 Ok(SpecContext {
122 schema: SPEC_SCHEMA,
123 topic: topic.to_owned(),
124 symbols,
125 docs,
126 related_adrs: related_adrs.into_iter().collect(),
127 })
128}
129
130fn edges_of(edges: &[rto_graph::EdgeRef], kind: &str) -> Vec<String> {
132 edges
133 .iter()
134 .filter(|e| e.kind == kind)
135 .map(|e| e.node.clone())
136 .collect()
137}
138
139#[must_use]
146pub fn scaffold_adr(
147 topic: &str,
148 title: Option<&str>,
149 adr_id: &str,
150 date: &str,
151 ctx: &SpecContext,
152) -> String {
153 use std::fmt::Write as _;
154
155 let title = title.unwrap_or(topic);
156 let Grounded {
157 symbol_links,
158 adr_links,
159 files,
160 } = grounded(ctx);
161
162 let mut out = String::new();
163 let _ = write!(
164 out,
165 "---\n\
166 Title: {title}\n\
167 Space: ARCH\n\
168 Parent: ADRs\n\n\
169 # ADR-specific metadata (unknown keys are ignored; used for indexing/search)\n\
170 type: adr\n\
171 adr-id: \"{adr_id}\"\n\
172 status: Draft # Draft | For Review | Accepted | Rejected | Superseded\n\
173 architectural-significance: MEDIUM # SOFT | LOW | MEDIUM | HIGH | VERY HIGH\n\
174 domain: Developer Tooling\n\
175 decision-makers: [\"The Roteiro Project Team\"]\n\
176 superseded-by:\n\
177 version: \"0.1\"\n\
178 last-modified: {date}\n\
179 confluence-url:\n\
180 ---\n\n\
181 # ADR-{adr_id}: {title}\n\n\
182 | | |\n|---|---|\n\
183 | **State** | Draft |\n\
184 | **Architectural Significance** | MEDIUM |\n\
185 | **Domain** | Developer Tooling |\n\
186 | **Document version** | 0.1 |\n\n\
187 ## Reference\n\n\
188 _Scaffolded by `roteiro spec` and grounded in the graph — the links below\n\
189 already resolve against real nodes; fill in the prose._\n\n"
190 );
191
192 if !adr_links.is_empty() {
193 let _ = writeln!(out, "Related decisions: {}.\n", adr_links.join(", "));
194 }
195 if !symbol_links.is_empty() {
196 let _ = writeln!(out, "Affected code: {}.\n", symbol_links.join(", "));
197 }
198
199 out.push_str(
200 "## Summary\n\n\
201 _TODO: the decision in a sentence or two._\n\n\
202 ## Context\n\n\
203 _TODO: the forces at play and why a decision is needed now._\n\n\
204 ## Interview — clarify before writing\n\n\
205 - [ ] What problem does this solve, and who has it?\n\
206 - [ ] Which existing ADRs does this relate to or supersede? (see Reference)\n\
207 - [ ] Are the affected symbols above the right scope — anything missing?\n\
208 - [ ] What options were considered, and why this one?\n\
209 - [ ] What are the consequences, costs, and risks?\n\n\
210 ## Decision makers\n\n\
211 - The Roteiro Project Team\n\n\
212 ## Recommended option\n\n_TODO._\n\n\
213 ## Options considered + consequences\n\n_TODO._\n\n\
214 ## Consequences\n\n_TODO._\n\n\
215 ## Build-plan outline (grounded)\n\n",
216 );
217
218 if files.is_empty() && adr_links.is_empty() {
219 out.push_str("_No related graph facts found for this topic yet._\n\n");
220 } else {
221 for f in &files {
222 let _ = writeln!(out, "- Touches `{f}`");
223 }
224 if !adr_links.is_empty() {
225 let _ = writeln!(out, "- Reconcile with: {}", adr_links.join(", "));
226 }
227 out.push('\n');
228 }
229
230 let _ = write!(
231 out,
232 "## Document version history\n\n\
233 | Version | Date | Notes |\n\
234 |---------|------|-------|\n\
235 | 0.1 | {date} | Draft scaffold generated by `roteiro spec scaffold`. |\n"
236 );
237 out
238}
239
240#[must_use]
247pub fn scaffold_blueprint(topic: &str, title: Option<&str>, ctx: &SpecContext) -> String {
248 use std::fmt::Write as _;
249
250 let title = title.unwrap_or(topic);
251 let Grounded {
252 symbol_links,
253 adr_links,
254 files,
255 } = grounded(ctx);
256
257 let mut out = String::new();
258 let _ = write!(
259 out,
260 "# {title} — Technical Implementation Plan\n\n\
261 _Scaffolded by `roteiro spec` and grounded in the graph — a build plan\n\
262 for {topic}. The links below resolve against real nodes; fill in the\n\
263 design._\n\n"
264 );
265 if !adr_links.is_empty() {
266 let _ = writeln!(out, "Grounded in: {}.\n", adr_links.join(", "));
267 }
268 if !symbol_links.is_empty() {
269 let _ = writeln!(out, "Touches: {}.\n", symbol_links.join(", "));
270 }
271
272 out.push_str("> **Status.** Design → build.\n\n---\n\n");
273 out.push_str(
274 "## 0. What this plan covers\n\n\
275 _TODO: the operator-facing surface (CLI/API) and scope._\n\n\
276 ## 1. Crate placement\n\n",
277 );
278 if files.is_empty() {
279 out.push_str("_TODO: which crates/modules this touches._\n\n");
280 } else {
281 for f in &files {
282 let _ = writeln!(out, "- `{f}`");
283 }
284 out.push('\n');
285 }
286 out.push_str(
287 "## 2. Design\n\n_TODO: the load-bearing decisions and how the pieces fit._\n\n\
288 ## 3. Interview — clarify before building\n\n\
289 - [ ] What is the operator-facing surface (CLI/API)?\n\
290 - [ ] Which crates/modules does this touch? (see Crate placement)\n\
291 - [ ] Which ADRs/decisions does it realise? (see grounding)\n\
292 - [ ] What are the phases / build order?\n\
293 - [ ] What are the risks and the invariants it must always satisfy?\n\n\
294 ## 4. Testing\n\n_TODO._\n\n\
295 ## 5. Phased build order\n\n_TODO._\n\n\
296 ## 6. Risks & invariants\n\n_TODO._\n",
297 );
298 out
299}
300
301struct Grounded {
304 symbol_links: Vec<String>,
305 adr_links: Vec<String>,
306 files: Vec<String>,
307}
308
309fn grounded(ctx: &SpecContext) -> Grounded {
312 let symbol_links = ctx
313 .symbols
314 .iter()
315 .filter_map(|s| symbol_link_target(&s.node.key))
316 .map(|t| format!("[[{t}]]"))
317 .collect();
318 let adr_links = ctx
319 .docs
320 .iter()
321 .filter(|d| d.kind == "adr")
322 .filter_map(|d| d.path.clone())
323 .map(|p| format!("[[{p}]]"))
324 .collect();
325 let mut files: Vec<String> = ctx
326 .symbols
327 .iter()
328 .filter_map(|s| s.node.path.clone())
329 .collect();
330 files.sort();
331 files.dedup();
332 Grounded {
333 symbol_links,
334 adr_links,
335 files,
336 }
337}
338
339fn symbol_link_target(key: &str) -> Option<&str> {
342 key.strip_prefix("sym:")
343 .and_then(|rest| rest.split_once(':'))
344 .map(|(_lang, target)| target)
345}
346
347#[must_use]
356pub fn draft_targets(scaffold_md: &str) -> Vec<(String, String)> {
357 let mut out = Vec::new();
358 let mut heading = String::new();
359 for line in scaffold_md.lines() {
360 if let Some(h) = line.strip_prefix("## ") {
361 h.trim().clone_into(&mut heading);
362 } else if let Some(hint) = todo_hint(line) {
363 out.push((heading.clone(), hint));
364 }
365 }
366 out
367}
368
369fn todo_hint(line: &str) -> Option<String> {
371 let rest = line.trim().strip_prefix("_TODO")?.strip_suffix('_')?;
372 Some(
375 rest.trim_start_matches([':', '.', ' '])
376 .trim_end_matches(['.', ' '])
377 .to_owned(),
378 )
379}
380
381#[must_use]
386pub fn draft_prompt(topic: &str, ctx: &SpecContext, heading: &str, hint: &str) -> String {
387 use std::fmt::Write as _;
388
389 let mut p = String::new();
390 let _ = write!(
391 p,
392 "You are drafting the \"{heading}\" section of a house-style technical \
393 document about \"{topic}\" for the Roteiro project (a provenance-tagged \
394 codebase knowledge graph). "
395 );
396 if !hint.is_empty() {
397 let _ = write!(p, "Focus: {hint}. ");
398 }
399 let symbols: Vec<&str> = ctx.symbols.iter().map(|s| s.node.name.as_str()).collect();
400 if !symbols.is_empty() {
401 let _ = write!(p, "Relevant code symbols: {}. ", symbols.join(", "));
402 }
403 if !ctx.related_adrs.is_empty() {
404 let _ = write!(p, "Related decisions: {}. ", ctx.related_adrs.join(", "));
405 }
406 p.push_str(
407 "Write 2–4 precise, technical sentences. Reference the real symbols above \
408 where relevant; do not invent symbols, files, or facts. Output only the \
409 prose, no heading.",
410 );
411 p
412}
413
414#[must_use]
418pub fn apply_drafts(scaffold_md: &str, drafts: &[(String, String)]) -> String {
419 let by_heading: std::collections::BTreeMap<&str, &str> = drafts
420 .iter()
421 .map(|(h, prose)| (h.as_str(), prose.as_str()))
422 .collect();
423 let mut out = String::new();
424 let mut heading = "";
425 for line in scaffold_md.lines() {
426 if let Some(h) = line.strip_prefix("## ") {
427 heading = h.trim();
428 } else if todo_hint(line).is_some()
429 && let Some(prose) = by_heading.get(heading)
430 {
431 out.push_str(prose);
432 out.push('\n');
433 continue;
434 }
435 out.push_str(line);
436 out.push('\n');
437 }
438 out
439}
440
441#[cfg(test)]
442mod tests {
443 use super::{SPEC_SCHEMA, context};
444 use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
445
446 fn seeded() -> Store {
447 let mut store = Store::open_in_memory().expect("store");
448 let facts = FactSet::new()
449 .with_node(Node::new("file:src/auth.rs", NodeKind::File, "auth.rs"))
450 .with_node(Node {
451 path: Some("src/auth.rs".to_owned()),
452 ..Node::new(
453 "sym:rust:src/auth.rs#validate_token",
454 NodeKind::Fn,
455 "validate_token",
456 )
457 })
458 .with_node(Node::new(
459 "sym:rust:src/auth.rs#login",
460 NodeKind::Fn,
461 "login",
462 ))
463 .with_node(Node::new(
464 "adr:0007",
465 NodeKind::Adr,
466 "Authentication design",
467 ))
468 .with_edge(Edge::derived(
470 "file:src/auth.rs",
471 "sym:rust:src/auth.rs#validate_token",
472 EdgeKind::Defines,
473 ))
474 .with_edge(Edge::derived(
475 "sym:rust:src/auth.rs#login",
476 "sym:rust:src/auth.rs#validate_token",
477 EdgeKind::Calls,
478 ))
479 .with_edge(Edge::authored(
480 "adr:0007",
481 "sym:rust:src/auth.rs#validate_token",
482 EdgeKind::References,
483 ));
484 store.apply_factset(&facts).expect("apply");
485 store
486 }
487
488 #[test]
489 fn context_grounds_a_symbol_in_its_neighbourhood() {
490 let store = seeded();
491 let ctx = context(&store, "validate_token", 10).expect("context");
492 assert_eq!(ctx.schema, SPEC_SCHEMA);
493
494 let sym = ctx
495 .symbols
496 .iter()
497 .find(|s| s.node.key == "sym:rust:src/auth.rs#validate_token")
498 .expect("the symbol");
499 assert_eq!(sym.container.as_deref(), Some("file:src/auth.rs"));
500 assert_eq!(sym.called_by, vec!["sym:rust:src/auth.rs#login"]);
501 assert_eq!(sym.authored_by, vec!["adr:0007"]);
502 assert!(ctx.related_adrs.contains(&"adr:0007".to_owned()));
504 }
505
506 #[test]
507 fn context_finds_related_docs_by_topic() {
508 let store = seeded();
509 let ctx = context(&store, "authentication", 10).expect("context");
511 assert!(
512 ctx.docs.iter().any(|d| d.key == "adr:0007"),
513 "the ADR should be a related doc: {:?}",
514 ctx.docs
515 );
516 assert!(ctx.related_adrs.contains(&"adr:0007".to_owned()));
517 }
518
519 #[test]
520 fn empty_topic_yields_empty_context() {
521 let store = seeded();
522 let ctx = context(&store, " ", 10).expect("context");
523 assert!(ctx.symbols.is_empty() && ctx.docs.is_empty());
524 }
525
526 #[test]
527 fn scaffold_is_grounded_and_check_clean() {
528 use super::scaffold_adr;
529 let mut store = seeded();
530 let ctx = context(&store, "validate_token", 10).expect("context");
531 let md = scaffold_adr(
532 "validate_token",
533 Some("Token validation"),
534 "0099",
535 "2026-08-09",
536 &ctx,
537 );
538
539 assert!(md.contains("adr-id: \"0099\""), "{md}");
541 assert!(md.contains("# ADR-0099: Token validation"));
542 assert!(
544 md.contains("[[src/auth.rs#validate_token]]"),
545 "grounded link: {md}"
546 );
547 assert!(md.contains("- [ ] What problem does this solve"));
548
549 let doc = crate::parse_adr("docs/adr/0099-token-validation.md", &md).expect("parse");
552 assert!(
553 doc.links
554 .iter()
555 .any(|l| l.target_key == "sym:rust:src/auth.rs#validate_token"),
556 "the scaffold's link must resolve to the real symbol: {:?}",
557 doc.links,
558 );
559 let report = crate::run(&mut store, std::slice::from_ref(&doc), &[], &[]).expect("check");
560 assert_eq!(
561 report.violations.len(),
562 0,
563 "scaffold must be check-clean: {:?}",
564 report.violations
565 );
566 }
567
568 #[test]
569 fn scaffold_has_no_code_block_indentation() {
570 use super::{scaffold_adr, scaffold_blueprint};
571 let store = seeded();
572 let ctx = context(&store, "validate_token", 10).expect("context");
573 let adr = scaffold_adr("validate_token", None, "0099", "2026-08-09", &ctx);
577 let blueprint = scaffold_blueprint("validate_token", None, &ctx);
578 for md in [&adr, &blueprint] {
579 for (i, line) in md.lines().enumerate() {
580 assert!(
581 !line.starts_with(' ') && !line.starts_with('\t'),
582 "line {} has leading whitespace: {line:?}",
583 i + 1
584 );
585 }
586 }
587 }
588
589 #[test]
590 fn draft_round_trip_fills_todo_sections() {
591 use super::{apply_drafts, draft_prompt, draft_targets, scaffold_adr};
592 let store = seeded();
593 let ctx = context(&store, "validate_token", 10).expect("context");
594 let scaffold = scaffold_adr("validate_token", None, "0099", "2026-08-09", &ctx);
595
596 let targets = draft_targets(&scaffold);
598 assert!(targets.iter().any(|(h, _)| h == "Summary"));
599 assert!(targets.iter().any(|(h, _)| h == "Context"));
600
601 let prompt = draft_prompt("validate_token", &ctx, "Summary", "the decision");
603 assert!(prompt.contains("validate_token"), "{prompt}");
604 assert!(prompt.contains("do not invent"));
605
606 let drafts: Vec<(String, String)> = targets
609 .iter()
610 .map(|(h, _)| (h.clone(), format!("Drafted prose for {h}.")))
611 .collect();
612 let filled = apply_drafts(&scaffold, &drafts);
613 assert!(filled.contains("Drafted prose for Summary."));
614 assert!(
615 !filled.contains("_TODO: the decision"),
616 "placeholder replaced: {filled}"
617 );
618 assert!(filled.contains("## Summary") && filled.contains("## Context"));
620 }
621
622 #[test]
623 fn blueprint_is_grounded_and_house_style() {
624 use super::scaffold_blueprint;
625 let store = seeded();
626 let ctx = context(&store, "validate_token", 10).expect("context");
627 let md = scaffold_blueprint("validate_token", Some("Token flow"), &ctx);
628
629 assert!(
632 md.starts_with("# Token flow — Technical Implementation Plan"),
633 "{md}"
634 );
635 assert!(
636 !md.contains("---\nTitle:"),
637 "blueprints have no frontmatter"
638 );
639 assert!(md.contains("> **Status.** Design → build."));
640 assert!(md.contains("## 1. Crate placement"));
641 assert!(
643 md.contains("[[src/auth.rs#validate_token]]"),
644 "grounded link: {md}"
645 );
646 assert!(md.contains("`src/auth.rs`"), "affected file listed: {md}");
647 assert!(md.contains("- [ ] What is the operator-facing surface"));
648 }
649}