use std::collections::BTreeSet;
use rto_graph::{NodeSummary, Store, StoreError, explain, search};
pub const SPEC_SCHEMA: &str = "roteiro.spec/v1";
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct SymbolContext {
pub node: NodeSummary,
pub container: Option<String>,
pub calls: Vec<String>,
pub called_by: Vec<String>,
pub authored_by: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct SpecContext {
pub schema: &'static str,
pub topic: String,
pub symbols: Vec<SymbolContext>,
pub docs: Vec<NodeSummary>,
pub related_adrs: Vec<String>,
}
const SYMBOL_KINDS: &[&str] = &["fn", "struct", "enum", "trait", "module"];
const DOC_KINDS: &[&str] = &["adr", "adr_section", "blueprint", "doc", "lat_section"];
pub fn context(store: &Store, topic: &str, limit: usize) -> Result<SpecContext, StoreError> {
if limit == 0 {
return Ok(SpecContext {
schema: SPEC_SCHEMA,
topic: topic.to_owned(),
symbols: Vec::new(),
docs: Vec::new(),
related_adrs: Vec::new(),
});
}
let hits = search(store, topic, limit.saturating_mul(3).max(30))?;
let mut symbols = Vec::new();
let mut docs = Vec::new();
let mut related_adrs: BTreeSet<String> = BTreeSet::new();
for hit in hits {
let kind = hit.node.kind.as_str();
if SYMBOL_KINDS.contains(&kind) {
if symbols.len() >= limit {
continue;
}
let Some(ex) = explain(store, &hit.node.key)? else {
continue;
};
let container = ex
.incoming
.iter()
.find(|e| e.kind == "contains" || e.kind == "defines")
.map(|e| e.node.clone());
let calls = edges_of(&ex.outgoing, "calls");
let called_by = edges_of(&ex.incoming, "calls");
let authored_by: Vec<String> = ex
.incoming
.iter()
.filter(|e| e.provenance == "authored")
.map(|e| e.node.clone())
.collect();
related_adrs.extend(authored_by.iter().cloned());
symbols.push(SymbolContext {
node: hit.node,
container,
calls,
called_by,
authored_by,
});
} else if DOC_KINDS.contains(&kind) {
if kind == "adr" || kind == "adr_section" {
related_adrs.insert(hit.node.key.clone());
}
if docs.len() < limit {
docs.push(hit.node);
}
}
}
Ok(SpecContext {
schema: SPEC_SCHEMA,
topic: topic.to_owned(),
symbols,
docs,
related_adrs: related_adrs.into_iter().collect(),
})
}
fn edges_of(edges: &[rto_graph::EdgeRef], kind: &str) -> Vec<String> {
edges
.iter()
.filter(|e| e.kind == kind)
.map(|e| e.node.clone())
.collect()
}
#[must_use]
pub fn scaffold_adr(
topic: &str,
title: Option<&str>,
adr_id: &str,
date: &str,
ctx: &SpecContext,
) -> String {
use std::fmt::Write as _;
let title = title.unwrap_or(topic);
let Grounded {
symbol_links,
adr_links,
files,
} = grounded(ctx);
let mut out = String::new();
let _ = write!(
out,
"---\n\
Title: {title}\n\
Space: ARCH\n\
Parent: ADRs\n\n\
# ADR-specific metadata (unknown keys are ignored; used for indexing/search)\n\
type: adr\n\
adr-id: \"{adr_id}\"\n\
status: Draft # Draft | For Review | Accepted | Rejected | Superseded\n\
architectural-significance: MEDIUM # SOFT | LOW | MEDIUM | HIGH | VERY HIGH\n\
domain: Developer Tooling\n\
decision-makers: [\"The Roteiro Project Team\"]\n\
superseded-by:\n\
version: \"0.1\"\n\
last-modified: {date}\n\
confluence-url:\n\
---\n\n\
# ADR-{adr_id}: {title}\n\n\
| | |\n|---|---|\n\
| **State** | Draft |\n\
| **Architectural Significance** | MEDIUM |\n\
| **Domain** | Developer Tooling |\n\
| **Document version** | 0.1 |\n\n\
## Reference\n\n\
_Scaffolded by `roteiro spec` and grounded in the graph — the links below\n\
already resolve against real nodes; fill in the prose._\n\n"
);
if !adr_links.is_empty() {
let _ = writeln!(out, "Related decisions: {}.\n", adr_links.join(", "));
}
if !symbol_links.is_empty() {
let _ = writeln!(out, "Affected code: {}.\n", symbol_links.join(", "));
}
out.push_str(
"## Summary\n\n\
_TODO: the decision in a sentence or two._\n\n\
## Context\n\n\
_TODO: the forces at play and why a decision is needed now._\n\n\
## Interview — clarify before writing\n\n\
- [ ] What problem does this solve, and who has it?\n\
- [ ] Which existing ADRs does this relate to or supersede? (see Reference)\n\
- [ ] Are the affected symbols above the right scope — anything missing?\n\
- [ ] What options were considered, and why this one?\n\
- [ ] What are the consequences, costs, and risks?\n\n\
## Decision makers\n\n\
- The Roteiro Project Team\n\n\
## Recommended option\n\n_TODO._\n\n\
## Options considered + consequences\n\n_TODO._\n\n\
## Consequences\n\n_TODO._\n\n\
## Build-plan outline (grounded)\n\n",
);
if files.is_empty() && adr_links.is_empty() {
out.push_str("_No related graph facts found for this topic yet._\n\n");
} else {
for f in &files {
let _ = writeln!(out, "- Touches `{f}`");
}
if !adr_links.is_empty() {
let _ = writeln!(out, "- Reconcile with: {}", adr_links.join(", "));
}
out.push('\n');
}
let _ = write!(
out,
"## Document version history\n\n\
| Version | Date | Notes |\n\
|---------|------|-------|\n\
| 0.1 | {date} | Draft scaffold generated by `roteiro spec scaffold`. |\n"
);
out
}
#[must_use]
pub fn scaffold_blueprint(topic: &str, title: Option<&str>, ctx: &SpecContext) -> String {
use std::fmt::Write as _;
let title = title.unwrap_or(topic);
let Grounded {
symbol_links,
adr_links,
files,
} = grounded(ctx);
let mut out = String::new();
let _ = write!(
out,
"# {title} — Technical Implementation Plan\n\n\
_Scaffolded by `roteiro spec` and grounded in the graph — a build plan\n\
for {topic}. The links below resolve against real nodes; fill in the\n\
design._\n\n"
);
if !adr_links.is_empty() {
let _ = writeln!(out, "Grounded in: {}.\n", adr_links.join(", "));
}
if !symbol_links.is_empty() {
let _ = writeln!(out, "Touches: {}.\n", symbol_links.join(", "));
}
out.push_str("> **Status.** Design → build.\n\n---\n\n");
out.push_str(
"## 0. What this plan covers\n\n\
_TODO: the operator-facing surface (CLI/API) and scope._\n\n\
## 1. Crate placement\n\n",
);
if files.is_empty() {
out.push_str("_TODO: which crates/modules this touches._\n\n");
} else {
for f in &files {
let _ = writeln!(out, "- `{f}`");
}
out.push('\n');
}
out.push_str(
"## 2. Design\n\n_TODO: the load-bearing decisions and how the pieces fit._\n\n\
## 3. Interview — clarify before building\n\n\
- [ ] What is the operator-facing surface (CLI/API)?\n\
- [ ] Which crates/modules does this touch? (see Crate placement)\n\
- [ ] Which ADRs/decisions does it realise? (see grounding)\n\
- [ ] What are the phases / build order?\n\
- [ ] What are the risks and the invariants it must always satisfy?\n\n\
## 4. Testing\n\n_TODO._\n\n\
## 5. Phased build order\n\n_TODO._\n\n\
## 6. Risks & invariants\n\n_TODO._\n",
);
out
}
struct Grounded {
symbol_links: Vec<String>,
adr_links: Vec<String>,
files: Vec<String>,
}
fn grounded(ctx: &SpecContext) -> Grounded {
let symbol_links = ctx
.symbols
.iter()
.filter_map(|s| symbol_link_target(&s.node.key))
.map(|t| format!("[[{t}]]"))
.collect();
let adr_links = ctx
.docs
.iter()
.filter(|d| d.kind == "adr")
.filter_map(|d| d.path.clone())
.map(|p| format!("[[{p}]]"))
.collect();
let mut files: Vec<String> = ctx
.symbols
.iter()
.filter_map(|s| s.node.path.clone())
.collect();
files.sort();
files.dedup();
Grounded {
symbol_links,
adr_links,
files,
}
}
fn symbol_link_target(key: &str) -> Option<&str> {
key.strip_prefix("sym:")
.and_then(|rest| rest.split_once(':'))
.map(|(_lang, target)| target)
}
#[must_use]
pub fn draft_targets(scaffold_md: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut heading = String::new();
for line in scaffold_md.lines() {
if let Some(h) = line.strip_prefix("## ") {
h.trim().clone_into(&mut heading);
} else if let Some(hint) = todo_hint(line) {
out.push((heading.clone(), hint));
}
}
out
}
fn todo_hint(line: &str) -> Option<String> {
let rest = line.trim().strip_prefix("_TODO")?.strip_suffix('_')?;
Some(
rest.trim_start_matches([':', '.', ' '])
.trim_end_matches(['.', ' '])
.to_owned(),
)
}
#[must_use]
pub fn draft_prompt(topic: &str, ctx: &SpecContext, heading: &str, hint: &str) -> String {
use std::fmt::Write as _;
let mut p = String::new();
let _ = write!(
p,
"You are drafting the \"{heading}\" section of a house-style technical \
document about \"{topic}\" for the Roteiro project (a provenance-tagged \
codebase knowledge graph). "
);
if !hint.is_empty() {
let _ = write!(p, "Focus: {hint}. ");
}
let symbols: Vec<&str> = ctx.symbols.iter().map(|s| s.node.name.as_str()).collect();
if !symbols.is_empty() {
let _ = write!(p, "Relevant code symbols: {}. ", symbols.join(", "));
}
if !ctx.related_adrs.is_empty() {
let _ = write!(p, "Related decisions: {}. ", ctx.related_adrs.join(", "));
}
p.push_str(
"Write 2–4 precise, technical sentences. Reference the real symbols above \
where relevant; do not invent symbols, files, or facts. Output only the \
prose, no heading.",
);
p
}
#[must_use]
pub fn apply_drafts(scaffold_md: &str, drafts: &[(String, String)]) -> String {
let by_heading: std::collections::BTreeMap<&str, &str> = drafts
.iter()
.map(|(h, prose)| (h.as_str(), prose.as_str()))
.collect();
let mut out = String::new();
let mut heading = "";
for line in scaffold_md.lines() {
if let Some(h) = line.strip_prefix("## ") {
heading = h.trim();
} else if todo_hint(line).is_some()
&& let Some(prose) = by_heading.get(heading)
{
out.push_str(prose);
out.push('\n');
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::{SPEC_SCHEMA, context};
use rto_graph::{Edge, EdgeKind, FactSet, Node, NodeKind, Store};
fn seeded() -> Store {
let mut store = Store::open_in_memory().expect("store");
let facts = FactSet::new()
.with_node(Node::new("file:src/auth.rs", NodeKind::File, "auth.rs"))
.with_node(Node {
path: Some("src/auth.rs".to_owned()),
..Node::new(
"sym:rust:src/auth.rs#validate_token",
NodeKind::Fn,
"validate_token",
)
})
.with_node(Node::new(
"sym:rust:src/auth.rs#login",
NodeKind::Fn,
"login",
))
.with_node(Node::new(
"adr:0007",
NodeKind::Adr,
"Authentication design",
))
.with_edge(Edge::derived(
"file:src/auth.rs",
"sym:rust:src/auth.rs#validate_token",
EdgeKind::Defines,
))
.with_edge(Edge::derived(
"sym:rust:src/auth.rs#login",
"sym:rust:src/auth.rs#validate_token",
EdgeKind::Calls,
))
.with_edge(Edge::authored(
"adr:0007",
"sym:rust:src/auth.rs#validate_token",
EdgeKind::References,
));
store.apply_factset(&facts).expect("apply");
store
}
#[test]
fn context_grounds_a_symbol_in_its_neighbourhood() {
let store = seeded();
let ctx = context(&store, "validate_token", 10).expect("context");
assert_eq!(ctx.schema, SPEC_SCHEMA);
let sym = ctx
.symbols
.iter()
.find(|s| s.node.key == "sym:rust:src/auth.rs#validate_token")
.expect("the symbol");
assert_eq!(sym.container.as_deref(), Some("file:src/auth.rs"));
assert_eq!(sym.called_by, vec!["sym:rust:src/auth.rs#login"]);
assert_eq!(sym.authored_by, vec!["adr:0007"]);
assert!(ctx.related_adrs.contains(&"adr:0007".to_owned()));
}
#[test]
fn context_finds_related_docs_by_topic() {
let store = seeded();
let ctx = context(&store, "authentication", 10).expect("context");
assert!(
ctx.docs.iter().any(|d| d.key == "adr:0007"),
"the ADR should be a related doc: {:?}",
ctx.docs
);
assert!(ctx.related_adrs.contains(&"adr:0007".to_owned()));
}
#[test]
fn empty_topic_yields_empty_context() {
let store = seeded();
let ctx = context(&store, " ", 10).expect("context");
assert!(ctx.symbols.is_empty() && ctx.docs.is_empty());
}
#[test]
fn scaffold_is_grounded_and_check_clean() {
use super::scaffold_adr;
let mut store = seeded();
let ctx = context(&store, "validate_token", 10).expect("context");
let md = scaffold_adr(
"validate_token",
Some("Token validation"),
"0099",
"2026-08-09",
&ctx,
);
assert!(md.contains("adr-id: \"0099\""), "{md}");
assert!(md.contains("# ADR-0099: Token validation"));
assert!(
md.contains("[[src/auth.rs#validate_token]]"),
"grounded link: {md}"
);
assert!(md.contains("- [ ] What problem does this solve"));
let doc = crate::parse_adr("docs/adr/0099-token-validation.md", &md).expect("parse");
assert!(
doc.links
.iter()
.any(|l| l.target_key == "sym:rust:src/auth.rs#validate_token"),
"the scaffold's link must resolve to the real symbol: {:?}",
doc.links,
);
let report = crate::run(&mut store, std::slice::from_ref(&doc), &[], &[]).expect("check");
assert_eq!(
report.violations.len(),
0,
"scaffold must be check-clean: {:?}",
report.violations
);
}
#[test]
fn scaffold_has_no_code_block_indentation() {
use super::{scaffold_adr, scaffold_blueprint};
let store = seeded();
let ctx = context(&store, "validate_token", 10).expect("context");
let adr = scaffold_adr("validate_token", None, "0099", "2026-08-09", &ctx);
let blueprint = scaffold_blueprint("validate_token", None, &ctx);
for md in [&adr, &blueprint] {
for (i, line) in md.lines().enumerate() {
assert!(
!line.starts_with(' ') && !line.starts_with('\t'),
"line {} has leading whitespace: {line:?}",
i + 1
);
}
}
}
#[test]
fn draft_round_trip_fills_todo_sections() {
use super::{apply_drafts, draft_prompt, draft_targets, scaffold_adr};
let store = seeded();
let ctx = context(&store, "validate_token", 10).expect("context");
let scaffold = scaffold_adr("validate_token", None, "0099", "2026-08-09", &ctx);
let targets = draft_targets(&scaffold);
assert!(targets.iter().any(|(h, _)| h == "Summary"));
assert!(targets.iter().any(|(h, _)| h == "Context"));
let prompt = draft_prompt("validate_token", &ctx, "Summary", "the decision");
assert!(prompt.contains("validate_token"), "{prompt}");
assert!(prompt.contains("do not invent"));
let drafts: Vec<(String, String)> = targets
.iter()
.map(|(h, _)| (h.clone(), format!("Drafted prose for {h}.")))
.collect();
let filled = apply_drafts(&scaffold, &drafts);
assert!(filled.contains("Drafted prose for Summary."));
assert!(
!filled.contains("_TODO: the decision"),
"placeholder replaced: {filled}"
);
assert!(filled.contains("## Summary") && filled.contains("## Context"));
}
#[test]
fn blueprint_is_grounded_and_house_style() {
use super::scaffold_blueprint;
let store = seeded();
let ctx = context(&store, "validate_token", 10).expect("context");
let md = scaffold_blueprint("validate_token", Some("Token flow"), &ctx);
assert!(
md.starts_with("# Token flow — Technical Implementation Plan"),
"{md}"
);
assert!(
!md.contains("---\nTitle:"),
"blueprints have no frontmatter"
);
assert!(md.contains("> **Status.** Design → build."));
assert!(md.contains("## 1. Crate placement"));
assert!(
md.contains("[[src/auth.rs#validate_token]]"),
"grounded link: {md}"
);
assert!(md.contains("`src/auth.rs`"), "affected file listed: {md}");
assert!(md.contains("- [ ] What is the operator-facing surface"));
}
}