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,
},
Debt {
#[arg(long, value_name = "CATEGORY")]
kind: Vec<String>,
#[arg(long)]
json: bool,
},
Path {
from: String,
to: String,
#[arg(long)]
json: bool,
},
Export {
#[arg(long)]
out: Option<String>,
},
Load {
file: String,
},
Import {
#[arg(long)]
from: String,
path: String,
#[arg(long)]
json: bool,
},
Render {
target: String,
#[arg(long)]
out: Option<String>,
},
Spec,
#[cfg(feature = "inference")]
Infer {
#[arg(long, default_value_t = 0.4)]
min_confidence: f64,
#[arg(long, default_value_t = 5)]
top_k: usize,
#[arg(long, value_name = "NAME")]
model: Option<String>,
#[arg(long)]
json: bool,
},
#[cfg(feature = "inference-local-models")]
Model {
#[command(subcommand)]
action: ModelAction,
},
#[cfg(feature = "mcp")]
Serve {
#[arg(long, value_name = "ADDR")]
http: Option<String>,
},
}
#[cfg(feature = "inference-local-models")]
#[derive(Subcommand)]
enum ModelAction {
List,
Pull {
name: String,
#[arg(long)]
yes: bool,
},
}
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::Debt { kind, json } => return run_debt(&kind, json),
Command::Path { from, to, json } => return run_path(&from, &to, json),
Command::Export { out } => return run_export(out),
Command::Load { file } => return run_load(&file),
Command::Init => return run_init(),
Command::Render { target, out } => return run_render(&target, out),
Command::Import { from, path, json } => return run_import(&from, &path, json),
Command::Spec => "spec",
#[cfg(feature = "inference")]
Command::Infer {
min_confidence,
top_k,
model,
json,
} => return run_infer(min_confidence, top_k, model.as_deref(), json),
#[cfg(feature = "inference-local-models")]
Command::Model { action } => return run_model(action),
#[cfg(feature = "mcp")]
Command::Serve { http } => return run_serve(http),
};
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);
store.reapply_imports()?;
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(),
);
println!("{}", debt_summary(&rto_graph::debt(&store, &[])?));
}
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(())
}
#[cfg(feature = "inference")]
fn run_infer(
min_confidence: f64,
top_k: usize,
model: Option<&str>,
json: bool,
) -> anyhow::Result<()> {
use rto_graph::{FactSet, InferenceConfig};
if !(0.0..=1.0).contains(&min_confidence) {
anyhow::bail!("--min-confidence must be in 0.0..=1.0 (got {min_confidence})");
}
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache)?;
store.delete_edges_by_src_ref(rto_graph::EMBED_REF)?;
let config = InferenceConfig {
min_confidence,
top_k,
};
let (edges, embedder_label) = infer_with_embedder(&store, config, model)?;
let count = edges.len();
store.apply_factset(&FactSet {
nodes: vec![],
edges,
})?;
if json {
let report = serde_json::json!({
"min_confidence": min_confidence,
"top_k": top_k,
"embedder": embedder_label,
"inferred_edges": count,
});
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!(
"inferred {count} similarity edge(s) via {embedder_label} \
(min-confidence {min_confidence}, top-k {top_k}); \
query them with `roteiro query <key>`",
);
}
Ok(())
}
#[cfg(all(feature = "inference", not(feature = "inference-local-models")))]
fn infer_with_embedder(
store: &rto_graph::Store,
config: rto_graph::InferenceConfig,
model: Option<&str>,
) -> anyhow::Result<(Vec<rto_graph::Edge>, String)> {
use rto_graph::infer_edges_with;
if model.is_some() {
anyhow::bail!(
"--model requires the `inference-local-models` feature; \
rebuild with `--features inference-local-models`"
);
}
Ok((
infer_edges_with(store, config, &rto_graph::HashEmbedder)?,
"hashing embedder (offline default)".to_owned(),
))
}
#[cfg(feature = "inference-local-models")]
fn infer_with_embedder(
store: &rto_graph::Store,
config: rto_graph::InferenceConfig,
model: Option<&str>,
) -> anyhow::Result<(Vec<rto_graph::Edge>, String)> {
use rto_graph::{HashEmbedder, LocalEmbedder, Platform, infer_edges_with};
let Some(name) = model else {
return Ok((
infer_edges_with(store, config, &HashEmbedder)?,
"hashing embedder (offline default)".to_owned(),
));
};
let spec = rto_graph::find_model(name)
.ok_or_else(|| anyhow::anyhow!("unknown model `{name}` (see `roteiro model list`)"))?;
let variant = spec
.variant_for(Platform::host())
.ok_or_else(|| anyhow::anyhow!("no variant of `{name}` for this platform"))?;
if !rto_graph::is_installed(name, variant) {
anyhow::bail!(
"model `{name}` is not installed — run `roteiro model pull {name}` \
(or omit --model to use the offline default)"
);
}
let dir = rto_graph::model_dir(name);
let embedder =
LocalEmbedder::load(&dir).map_err(|e| anyhow::anyhow!("loading model `{name}`: {e}"))?;
let edges = infer_edges_with(store, config, &EmbedderAdapter(&embedder))?;
Ok((
edges,
format!("local model `{name}` (dim {})", embedder.dim()),
))
}
#[cfg(feature = "inference-local-models")]
struct EmbedderAdapter<'a>(&'a rto_graph::LocalEmbedder);
#[cfg(feature = "inference-local-models")]
impl rto_graph::Embedder for EmbedderAdapter<'_> {
fn embed(&self, text: &str) -> Vec<f32> {
self.0.embed(text).unwrap_or_default()
}
}
#[cfg(feature = "inference-local-models")]
fn run_model(action: ModelAction) -> anyhow::Result<()> {
match action {
ModelAction::List => {
run_model_list();
Ok(())
}
ModelAction::Pull { name, yes } => run_model_pull(&name, yes),
}
}
#[cfg(feature = "inference-local-models")]
fn run_model_list() {
use rto_graph::{Platform, REGISTRY};
let host = Platform::host();
println!(
"platform: {} model store: {}",
host.as_str(),
rto_graph::store_root().display()
);
println!("(the built-in hashing embedder is always available with no model)\n");
for spec in REGISTRY {
let variant = spec.variant_for(host);
let installed = variant.is_some_and(|v| rto_graph::is_installed(spec.name, v));
let mark = if installed {
"✓ installed"
} else {
" available"
};
println!(
"{mark} {name} (dim {dim}, {licence}, ~{size} MiB)\n {desc}",
name = spec.name,
dim = spec.dim,
licence = spec.licence,
size = spec.size_mib,
desc = spec.description,
);
}
}
#[cfg(feature = "inference-local-models")]
fn run_model_pull(name: &str, yes: bool) -> anyhow::Result<()> {
use rto_graph::{Platform, ensure_model_dir, find_model, verify_sha256};
use std::io::Write as _;
let spec = find_model(name)
.ok_or_else(|| anyhow::anyhow!("unknown model `{name}` (see `roteiro model list`)"))?;
let variant = spec
.variant_for(Platform::host())
.ok_or_else(|| anyhow::anyhow!("no variant of `{name}` for this platform"))?;
let stdin_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdin());
if !yes {
eprintln!(
"roteiro would download model `{name}` (~{} MiB, {}) from:",
spec.size_mib, spec.licence
);
for f in variant.files {
eprintln!(" {}", f.url);
}
if !stdin_is_tty {
eprintln!(
"\nnon-interactive: not downloading. Re-run with `--yes`, or fetch manually into {}",
rto_graph::model_dir(name).display()
);
anyhow::bail!("download declined (non-interactive)");
}
eprint!("Download now? [y/N] ");
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
anyhow::bail!("download declined");
}
}
let dir = ensure_model_dir(name)?;
for f in variant.files {
let dest = dir.join(f.name);
eprintln!("fetching {} …", f.name);
let bytes = http_get(f.url)?;
if f.sha256.is_empty() {
eprintln!(
" warning: no checksum pinned for {} — integrity NOT verified",
f.name
);
} else if !verify_sha256(&bytes, f.sha256) {
anyhow::bail!(
"checksum mismatch for {} (expected {}, got {})",
f.name,
f.sha256,
rto_graph::sha256_hex(&bytes),
);
}
let tmp = dir.join(format!("{}.partial", f.name));
std::fs::write(&tmp, &bytes)?;
if dest.exists() {
std::fs::remove_file(&dest)?;
}
std::fs::rename(&tmp, &dest)?;
}
println!(
"installed `{name}` → {} (use it with `roteiro infer --model {name}`)",
dir.display()
);
Ok(())
}
#[cfg(feature = "inference-local-models")]
fn http_get(url: &str) -> anyhow::Result<Vec<u8>> {
let mut reader = ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("GET {url}: {e}"))?
.into_body()
.into_reader();
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut reader, &mut bytes)?;
Ok(bytes)
}
fn run_import(from: &str, path: &str, json: bool) -> anyhow::Result<()> {
match from {
"graphify" => run_import_graphify(path, json),
"lat" => run_import_lat(path, json),
"codegraph" => run_compare_codegraph(path, json),
other => {
anyhow::bail!("unknown import source `{other}` (expected: graphify | lat | codegraph)")
}
}
}
fn run_compare_codegraph(path: &str, json: bool) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache)?;
let report = rto_graph::compare_codegraph(std::path::Path::new(path), &store)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
if let Some(commit) = &report.source_commit {
let short = &commit[..commit.len().min(12)];
println!("codegraph oracle — snapshot indexed at {short}");
}
println!(
"symbols: {} matched, {} scope-only diffs (same symbol, different \
module scope), {} codegraph-only, {} roteiro-only \
(codegraph {}, roteiro {}; {} constants are a known Roteiro gap)",
report.symbols_matched,
report.symbols_scope_diff,
report.codegraph_only,
report.roteiro_only,
report.symbols_codegraph,
report.symbols_roteiro,
report.constants_codegraph,
);
println!(
"calls: {}/{} codegraph internal calls agree ({} not re-derived — \
Roteiro links only unambiguous calls)",
report.calls_agree, report.calls_codegraph, report.calls_codegraph_only,
);
for key in report.codegraph_only_sample.iter().take(10) {
println!(" codegraph-only: {key}");
}
for key in report.roteiro_only_sample.iter().take(10) {
println!(" roteiro-only: {key}");
}
}
Ok(())
}
fn run_import_lat(path: &str, json: bool) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
let root = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("cannot import into a bare repository"))?;
let cwd = std::env::current_dir()?;
let dir = {
let p = std::path::Path::new(path);
if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
}
};
if !dir.is_dir() {
anyhow::bail!(
"lat directory not found: {} (expected a lat.md/ dir)",
dir.display()
);
}
let mut files = Vec::new();
collect_markdown(&dir, root, &mut files)?;
files.sort_by(|a, b| a.0.cmp(&b.0));
if files.is_empty() {
anyhow::bail!("no .md files under {}", dir.display());
}
let imported = rto_spec::import_lat(&files);
build_graph(&repo, &mut store, &cache)?;
let applied = store.apply_import_layer(rto_spec::LAT_REF, &imported.facts)?;
let r = &imported.report;
if json {
let mut report = serde_json::to_value(r)?;
report["edges_applied"] = serde_json::json!(applied.edges_applied);
report["edges_pruned_stale"] = serde_json::json!(applied.edges_pruned);
report["durable"] = serde_json::json!(true);
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!(
"imported lat.md: {} file(s), {} section(s), {} link(s) \
({} to sections, {} to code); {} edge(s) applied, {} stale pruned — persisted (durable)",
r.files,
r.sections,
r.links_total,
r.links_to_sections,
r.links_to_code,
applied.edges_applied,
applied.edges_pruned,
);
}
Ok(())
}
fn collect_markdown(
dir: &std::path::Path,
root: &std::path::Path,
out: &mut Vec<(String, String)>,
) -> anyhow::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
if file_type.is_dir() {
collect_markdown(&path, root, out)?;
} else if file_type.is_file() && path.extension().and_then(|e| e.to_str()) == Some("md") {
let rel = path.strip_prefix(root).map_err(|_| {
anyhow::anyhow!(
"lat file {} is outside the repository ({}); the lat.md \
directory must live inside the repo",
path.display(),
root.display()
)
})?;
let rel = rel.to_string_lossy().replace('\\', "/");
out.push((rel, std::fs::read_to_string(&path)?));
}
}
Ok(())
}
fn run_import_graphify(path: &str, json: bool) -> anyhow::Result<()> {
use rto_graph::{Edge, EdgeKind};
let p = std::path::Path::new(path);
let graph_json = if p.is_dir() {
p.join("graph.json")
} else {
p.to_path_buf()
};
let text = std::fs::read_to_string(&graph_json)
.map_err(|e| anyhow::anyhow!("reading {}: {e}", graph_json.display()))?;
let imported = rto_spec::import_graphify(&text)?;
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache)?;
let mut facts = imported.facts.clone();
let mut linked = 0usize;
for node in &imported.facts.nodes {
if let Some(path) = &node.path {
let file_key = format!("file:{path}");
if store.get_node(&file_key)?.is_some() {
let mut edge =
Edge::inferred(node.key.clone(), file_key, EdgeKind::References, 0.9);
edge.src_ref = Some(rto_spec::GRAPHIFY_REF.to_owned());
facts.edges.push(edge);
linked += 1;
}
}
}
let applied = store.apply_import_layer(rto_spec::GRAPHIFY_REF, &facts)?;
let r = &imported.report;
if json {
let mut report = serde_json::to_value(r)?;
report["docs_linked_to_files"] = serde_json::json!(linked);
report["edges_pruned_stale"] = serde_json::json!(applied.edges_pruned);
report["durable"] = serde_json::json!(true);
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!(
"imported graphify: {} node(s) ({} dropped as code), {} inferred edge(s) \
({} ast dropped, {} dangling skipped), {} hyperedge group(s); \
{linked} doc(s) linked to files, {} stale pruned — persisted (durable)",
r.nodes_imported,
r.nodes_dropped_code,
r.edges_imported,
r.edges_dropped_ast,
r.edges_skipped_dangling,
r.hyperedges_imported,
applied.edges_pruned,
);
}
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_debt(kinds: &[String], json: bool) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache)?;
let report = rto_graph::debt(&store, kinds)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
for item in &report.items {
let loc = match (&item.path, item.line) {
(Some(p), Some(l)) => format!("{p}:{l}"),
(Some(p), None) => p.clone(),
_ => item.key.clone(),
};
println!(" [{}] {loc} {}", item.category, item.text);
}
println!("{}", debt_summary(&report));
}
Ok(())
}
fn debt_summary(report: &rto_graph::DebtReport) -> String {
if report.total == 0 {
return "intent debt: none".to_owned();
}
let breakdown: Vec<String> = report
.by_category
.iter()
.map(|(cat, n)| format!("{cat} {n}"))
.collect();
format!(
"intent debt: {} marker(s) ({})",
report.total,
breakdown.join(", ")
)
}
fn run_path(from: &str, to: &str, json: bool) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache)?;
let result = rto_graph::path(&store, from, to)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else if result.found {
println!("{from}");
for hop in &result.hops {
let arrow = if hop.direction == "outgoing" {
"->"
} else {
"<-"
};
println!(" {arrow}[{}/{}] {}", hop.kind, hop.provenance, hop.node);
}
println!("({} hop(s))", result.length);
} else {
eprintln!("no path from `{from}` to `{to}`");
}
if !result.found {
std::process::exit(1);
}
Ok(())
}
fn run_export(out: Option<String>) -> anyhow::Result<()> {
use rto_graph::GraphArtifact;
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache)?;
let artifact = GraphArtifact::from_store(&store)?;
let json = artifact.to_json()?;
let out = out.unwrap_or_else(|| "roteiro-graph.json".to_owned());
if out == "-" {
println!("{json}");
} else {
std::fs::write(&out, format!("{json}\n"))?;
eprintln!(
"exported {} nodes, {} edges → {out}",
artifact.facts.nodes.len(),
artifact.facts.edges.len()
);
}
Ok(())
}
fn run_load(file: &str) -> anyhow::Result<()> {
use rto_graph::{GraphArtifact, Repo, Store};
let json = if file == "-" {
std::io::read_to_string(std::io::stdin())?
} else {
std::fs::read_to_string(file)?
};
let artifact = GraphArtifact::from_json(&json)?;
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"))?;
artifact.load_into(&mut store)?;
println!(
"loaded {} nodes, {} edges from {file}",
store.node_count()?,
store.edge_count()?
);
Ok(())
}
#[cfg(feature = "mcp")]
fn run_serve(http: Option<String>) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache)?;
match http {
Some(addr) => {
let addr: std::net::SocketAddr = addr
.parse()
.map_err(|e| anyhow::anyhow!("invalid --http address `{addr}`: {e}"))?;
eprintln!("roteiro MCP server listening on http://{addr}/mcp");
rto_render::mcp::serve_http(store, addr).map_err(|e| anyhow::anyhow!("{e}"))
}
None => rto_render::mcp::serve_stdio(store).map_err(|e| anyhow::anyhow!("{e}")),
}
}
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,
});
}
let mut lifetime = Vec::new();
let build_plan = root.join("docs/BUILD_PLAN.md");
if build_plan.is_file() {
let md = std::fs::read_to_string(&build_plan)?;
let rendered = rto_render::render_doc(&md, "Build Plan");
std::fs::write(out.join("build-plan.html"), &rendered.html)?;
lifetime.push(rto_render::IndexEntry {
href: "../build-plan.html".to_owned(),
title: rendered.title,
});
}
std::fs::write(
out.join("adr").join("index.html"),
rto_render::render_adr_index(&lifetime, &entries),
)?;
println!(
"rendered docs → {} ({} ADR page(s), {} lifetime doc(s))",
out.display(),
entries.len(),
lifetime.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(())
}