use clap::{Parser, Subcommand};
mod init;
#[derive(Parser)]
#[command(
name = "roteiro",
version,
about = "Provenance-tagged codebase knowledge graph",
long_about = "Roteiro — the pilot book for your codebase.\n\n\
One SQLite store holding structure, intent, and context as a single \
provenance-tagged knowledge graph, queryable by humans and AI agents \
alike. Subcommands are scaffolds while the graph core lands; see \
ADR-0001 and docs/BUILD_PLAN.md for the roadmap.",
arg_required_else_help = true,
propagate_version = true
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Init,
Sync {
#[arg(long)]
json: bool,
#[arg(long)]
committed: bool,
},
Check {
#[arg(long)]
json: bool,
},
Query {
key: Option<String>,
#[arg(long, conflicts_with = "key")]
kind: Option<String>,
#[arg(long)]
json: bool,
},
Import {
#[arg(long)]
from: String,
},
Render {
target: String,
#[arg(long)]
out: Option<String>,
},
Spec,
#[cfg(feature = "mcp")]
Serve,
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let name = match cli.command {
Command::Sync { json, committed } => return run_sync(json, committed),
Command::Check { json } => return run_check(json),
Command::Query { key, kind, json } => return run_query(key, kind, json),
Command::Init => return run_init(),
Command::Render { target, out } => return run_render(&target, out),
Command::Import { .. } => "import",
Command::Spec => "spec",
#[cfg(feature = "mcp")]
Command::Serve => "serve",
};
anyhow::bail!("`roteiro {name}` is not implemented yet (scaffold; see docs/BUILD_PLAN.md)")
}
fn run_sync(json: bool, committed_only: bool) -> anyhow::Result<()> {
use rto_graph::{ObjectCache, Registry, Repo, Store, sync, sync_worktree};
let cwd = std::env::current_dir()?;
let repo = Repo::discover(&cwd)?;
let store_dir = repo.git_dir().join("roteiro");
std::fs::create_dir_all(&store_dir)?;
let mut store = Store::open(&store_dir.join("graph.db"))?;
let cache = ObjectCache::open(repo.common_dir().join("roteiro").join("objects"))?;
let report = if committed_only {
sync(&mut store, &repo, &cache, &Registry)?
} else {
sync_worktree(&mut store, &repo, &cache, &Registry)?
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
let tree = &report.tree[..report.tree.len().min(12)];
let dirty = if report.blobs_dirty > 0 {
format!(" +{} uncommitted", report.blobs_dirty)
} else {
String::new()
};
if report.no_op {
println!(
"up to date (tree {tree}{dirty}) — {} nodes, {} edges",
report.nodes, report.edges
);
} else {
println!(
"synced tree {tree}{dirty} — {} blobs ({} extracted, {} cached) → {} nodes, {} edges",
report.blobs_total,
report.blobs_extracted,
report.blobs_cached,
report.nodes,
report.edges
);
}
}
Ok(())
}
fn open_graph() -> anyhow::Result<(rto_graph::Repo, rto_graph::Store, rto_graph::ObjectCache)> {
use rto_graph::{ObjectCache, Repo, Store};
let cwd = std::env::current_dir()?;
let repo = Repo::discover(&cwd)?;
let store_dir = repo.git_dir().join("roteiro");
std::fs::create_dir_all(&store_dir)?;
let store = Store::open(&store_dir.join("graph.db"))?;
let cache = ObjectCache::open(repo.common_dir().join("roteiro").join("objects"))?;
Ok((repo, store, cache))
}
fn build_graph(
repo: &rto_graph::Repo,
store: &mut rto_graph::Store,
cache: &rto_graph::ObjectCache,
) -> anyhow::Result<rto_spec::CheckReport> {
use rto_graph::{Registry, sync};
sync(store, repo, cache, &Registry)?;
let mut docs = Vec::new();
let mut annotations = Vec::new();
let mut malformed = Vec::new();
for blob in repo.walk_blobs()? {
let bytes = repo.read_blob(&blob.oid)?;
let text = String::from_utf8_lossy(&bytes);
let file = std::path::Path::new(&blob.path);
let is_md = file
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("md"));
let name = file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let is_adr = blob.path.starts_with("docs/adr/") && is_md && name != "README.md";
if is_adr {
match rto_spec::parse_adr(&blob.path, &text) {
Ok(doc) => docs.push(doc),
Err(e) => malformed.push(rto_spec::Violation {
kind: rto_spec::ViolationKind::MalformedAdr,
message: format!("{}: cannot parse ADR: {e}", blob.path),
}),
}
} else {
annotations.extend(rto_spec::scan_annotations(&blob.path, &text));
}
}
let mut report = rto_spec::run(store, &docs, &annotations)?;
report.violations.extend(malformed);
Ok(report)
}
fn run_check(json: bool) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
let report = build_graph(&repo, &mut store, &cache)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
for v in &report.violations {
eprintln!("drift [{}]: {}", v.kind.label(), v.message);
}
println!(
"checked {} ADR(s): {} link(s) ok, {} annotation(s) ok, {} violation(s)",
report.adrs,
report.links_ok,
report.annotations_ok,
report.violations.len(),
);
}
if report.has_violations() {
std::process::exit(1);
}
Ok(())
}
fn run_init() -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
let report = build_graph(&repo, &mut store, &cache)?;
let nodes = store.node_count()?;
let edges = store.edge_count()?;
let hooks_dir = repo.common_dir().join("hooks");
for name in init::MANAGED_HOOKS {
match init::install_hook(&hooks_dir, name)? {
init::HookOutcome::Installed => println!("installed hook: {name}"),
init::HookOutcome::Updated => println!("refreshed hook: {name}"),
init::HookOutcome::SkippedForeign => eprintln!(
"warning: existing non-Roteiro `{name}` hook left untouched; \
add `roteiro sync --committed` to it to keep the graph fresh"
),
}
}
if let Some(workdir) = repo.workdir() {
let path = workdir.join("AGENTS.md");
if init::ensure_agents(&path)? {
println!("wrote Roteiro section to {}", path.display());
}
}
println!("roteiro initialised — graph has {nodes} nodes, {edges} edges");
if report.has_violations() {
eprintln!(
"note: {} authored-layer violation(s); run `roteiro check` for details",
report.violations.len()
);
}
Ok(())
}
fn run_query(key: Option<String>, kind: Option<String>, json: bool) -> anyhow::Result<()> {
use rto_graph::{NodeKind, explain, list_kind};
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache)?;
match (key, kind) {
(Some(key), _) => {
let Some(ex) = explain(&store, &key)? else {
anyhow::bail!(
"no node with key `{key}` (try `roteiro query --kind <kind>` to list nodes)"
);
};
if json {
println!("{}", serde_json::to_string_pretty(&ex)?);
} else {
println!("{} ({}) {}", ex.node.key, ex.node.kind, ex.node.name);
if let Some(path) = &ex.node.path {
println!(" path: {path}");
}
if !ex.outgoing.is_empty() {
println!(" outgoing:");
for e in &ex.outgoing {
println!(" -[{}/{}]-> {}", e.kind, e.provenance, e.node);
}
}
if !ex.incoming.is_empty() {
println!(" incoming:");
for e in &ex.incoming {
println!(" <-[{}/{}]- {}", e.kind, e.provenance, e.node);
}
}
}
}
(None, Some(kind)) => {
let listing = list_kind(&store, &NodeKind::from_token(&kind))?;
if json {
println!("{}", serde_json::to_string_pretty(&listing)?);
} else {
println!("{} ({}):", listing.kind, listing.nodes.len());
for n in &listing.nodes {
println!(" {} {}", n.key, n.name);
}
}
}
(None, None) => {
anyhow::bail!("provide a node key to explain, or `--kind <kind>` to list nodes");
}
}
Ok(())
}
fn run_render(target: &str, out: Option<String>) -> anyhow::Result<()> {
match rto_render::Target::parse(target) {
Some(rto_render::Target::DocsSite) => render_docs(out),
Some(rto_render::Target::ObsidianVault) => render_obsidian(out),
None => anyhow::bail!("unknown render target `{target}` (expected: docs | obsidian)"),
}
}
fn render_docs(out: Option<String>) -> anyhow::Result<()> {
let cwd = std::env::current_dir()?;
let repo = rto_graph::Repo::discover(&cwd)?;
let root = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("cannot render docs in a bare repository"))?;
let out = out.map_or_else(|| root.join("website/dist"), std::path::PathBuf::from);
if out.exists() {
std::fs::remove_dir_all(&out)?;
}
std::fs::create_dir_all(out.join("adr"))?;
copy_dir(&root.join("website/public"), &out)?;
let adr_dir = root.join("docs/adr");
let mut files: Vec<_> = std::fs::read_dir(&adr_dir)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
.filter(|p| p.file_name().and_then(|n| n.to_str()) != Some("README.md"))
.collect();
files.sort();
let mut entries = Vec::new();
for path in &files {
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("adr");
let md = std::fs::read_to_string(path)?;
let rendered = rto_render::render_adr(&md, stem);
std::fs::write(out.join("adr").join(format!("{stem}.html")), &rendered.html)?;
entries.push(rto_render::IndexEntry {
href: format!("{stem}.html"),
title: rendered.title,
});
}
std::fs::write(
out.join("adr").join("index.html"),
rto_render::render_adr_index(&entries),
)?;
println!(
"rendered docs → {} ({} ADR page(s))",
out.display(),
entries.len()
);
Ok(())
}
fn render_obsidian(out: Option<String>) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache)?;
let out = out.map_or_else(
|| std::path::PathBuf::from("vault"),
std::path::PathBuf::from,
);
if out.exists() {
std::fs::remove_dir_all(&out)?;
}
std::fs::create_dir_all(&out)?;
let mut count = 0usize;
for key in store.all_keys()? {
if let Some(ex) = rto_graph::explain(&store, &key)? {
let note = rto_render::render_note(&ex);
std::fs::write(out.join(¬e.filename), ¬e.content)?;
count += 1;
}
}
println!(
"rendered obsidian vault → {} ({count} note(s))",
out.display()
);
Ok(())
}
fn copy_dir(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_dir(&from, &to)?;
} else {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}