use clap::{Parser, Subcommand};
mod config;
#[cfg(feature = "explorer")]
mod graph_api;
#[cfg(feature = "explorer")]
mod explorer_app;
mod infer_links;
mod init;
mod overview;
mod pins;
mod review;
mod telemetry;
#[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,
#[command(flatten)]
log: LogArgs,
}
#[derive(clap::Args, Debug)]
#[allow(clippy::struct_field_names)]
struct LogArgs {
#[arg(
long = "log-file",
global = true,
value_name = "PATH",
env = "ROTEIRO_LOG_FILE"
)]
log_file: Option<String>,
#[arg(long = "log", global = true)]
log_enable: bool,
#[arg(
long = "log-rotation",
global = true,
value_name = "CADENCE",
env = "ROTEIRO_LOG_ROTATION"
)]
log_rotation: Option<String>,
#[arg(
long = "log-format",
global = true,
value_name = "FORMAT",
env = "ROTEIRO_LOG_FORMAT"
)]
log_format: Option<String>,
}
impl LogArgs {
fn overrides(&self) -> telemetry::Overrides {
telemetry::Overrides {
file: self.log_file.clone(),
enable_default: self.log_enable,
rotation: self.log_rotation.clone(),
format: self.log_format.clone(),
}
}
}
#[derive(Subcommand)]
enum Command {
Init {
#[arg(long)]
fetch: bool,
#[arg(long)]
vault: bool,
},
Sync {
#[arg(long)]
json: bool,
#[arg(long)]
committed: bool,
},
Review {
#[arg(long)]
json: bool,
#[arg(long)]
base: Option<String>,
},
Check {
#[arg(long)]
json: bool,
#[arg(long, conflicts_with = "staged")]
committed: bool,
#[arg(long)]
staged: bool,
},
Query {
key: Option<String>,
#[arg(long, conflicts_with = "key")]
kind: Option<String>,
#[arg(long)]
app_config_only: bool,
#[arg(long)]
json: bool,
},
Search {
query: String,
#[arg(long, default_value_t = 10)]
limit: usize,
#[arg(long)]
json: bool,
},
Context {
key: Option<String>,
#[arg(long, conflicts_with = "key")]
refresh: bool,
#[arg(long)]
json: bool,
},
Config {
#[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,
},
Links {
#[arg(long, value_name = "ROOT")]
workspace: Vec<String>,
#[arg(long = "workspace-name", short = 'w', value_name = "NAME")]
workspace_name: Option<String>,
#[arg(long, conflicts_with = "matrix")]
infer: bool,
#[arg(long)]
matrix: bool,
#[arg(long, value_name = "PROJECT")]
hub: Option<String>,
#[arg(long, value_name = "REV")]
hub_rev: Option<String>,
#[arg(long, requires = "infer", conflicts_with_all = ["matrix", "hub_rev"])]
pinned: bool,
#[arg(long, requires = "infer")]
write: bool,
#[arg(long, requires = "matrix")]
html: bool,
#[arg(long, value_name = "FILE", requires = "html")]
out: Option<String>,
#[arg(long)]
app_config_only: bool,
#[arg(long)]
json: bool,
},
Export {
#[arg(long)]
out: Option<String>,
},
Load {
file: String,
#[arg(long)]
force: bool,
},
Import {
#[arg(long)]
from: String,
path: String,
#[arg(long)]
json: bool,
},
Render {
target: String,
#[arg(long)]
out: Option<String>,
},
Spec {
#[command(subcommand)]
action: SpecAction,
},
#[cfg(feature = "inference")]
Infer {
#[arg(long)]
min_confidence: Option<f64>,
#[arg(long)]
top_k: Option<usize>,
#[arg(long, value_name = "NAME")]
model: Option<String>,
#[arg(long)]
json: bool,
},
#[cfg(feature = "inference")]
#[command(visible_alias = "dup")]
Duplicates {
#[arg(long)]
min_similarity: Option<f64>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
json: bool,
},
#[cfg(feature = "models")]
Model {
#[command(subcommand)]
action: ModelAction,
},
#[cfg(feature = "execution")]
Security {
#[command(subcommand)]
action: SecurityAction,
},
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
Serve {
#[arg(long, value_name = "ADDR")]
addr: Option<String>,
#[arg(long, value_name = "FILE")]
tls_cert: Option<String>,
#[arg(long, value_name = "FILE")]
tls_key: Option<String>,
#[arg(long, value_name = "ROOT")]
workspace: Vec<String>,
#[arg(long = "workspace-name", short = 'w', value_name = "NAME")]
workspace_name: Option<String>,
#[arg(long)]
sync_on_access: bool,
#[arg(long)]
mcp: bool,
#[arg(long, hide = true)]
models: bool,
#[arg(
long,
value_name = "ADDR",
hide = true,
conflicts_with_all = ["models", "addr", "tls_cert", "tls_key", "mcp"]
)]
http: Option<String>,
},
#[cfg(any(feature = "mcp", feature = "serve"))]
Mcp {
#[arg(long, value_name = "ADDR")]
http: Option<String>,
#[arg(long, value_name = "ROOT")]
workspace: Vec<String>,
#[arg(long = "workspace-name", short = 'w', value_name = "NAME")]
workspace_name: Option<String>,
#[arg(long)]
sync_on_access: bool,
},
#[cfg(feature = "explorer")]
Explorer {
#[arg(long, value_name = "ADDR")]
addr: Option<String>,
#[arg(long = "workspace-name", short = 'w', value_name = "NAME")]
workspace_name: Option<String>,
},
}
#[derive(Subcommand)]
enum SpecAction {
Context {
topic: String,
#[arg(long, default_value_t = 10)]
limit: usize,
#[arg(long)]
json: bool,
},
Scaffold {
topic: String,
#[arg(long)]
title: Option<String>,
#[arg(long, default_value = "adr")]
kind: String,
#[arg(long)]
out: Option<String>,
},
Draft {
topic: String,
#[arg(long)]
title: Option<String>,
#[arg(long, default_value = "adr")]
kind: String,
#[arg(long)]
out: Option<String>,
},
}
#[cfg(feature = "models")]
#[derive(Subcommand)]
enum ModelAction {
List,
Pull {
name: String,
#[arg(long)]
yes: bool,
},
}
#[cfg(feature = "execution")]
#[derive(Subcommand)]
enum SecurityAction {
Ingest {
file: String,
#[arg(long)]
json: bool,
},
List {
#[arg(long, value_name = "NAME")]
analyzer: Option<String>,
#[arg(long)]
json: bool,
},
}
fn exit_gate_failure() -> ! {
let _released = rto_graph::release_media_engines();
#[cfg(feature = "inference-local-models")]
let _backend = rto_llama::backend::release_shared_backend();
std::process::exit(1)
}
#[allow(clippy::too_many_lines)]
fn main() -> anyhow::Result<()> {
sigpipe::reset();
let cli = Cli::parse();
let cwd = std::env::current_dir()?;
let cfg = config::load(&cwd)?;
let _log_guard = telemetry::init(&cli.log.overrides(), &cfg.effective.telemetry)?;
let ingest = cfg.effective.ingest.resolve();
let debt_ignore: &[String] = cfg.effective.debt.ignore.as_deref().unwrap_or(&[]);
if let Some(dir) = cfg.effective.paths.model_store.as_deref() {
let dir = config::expand_tilde(dir).into_owned();
#[cfg(feature = "models")]
rto_graph::set_model_store(dir);
#[cfg(not(feature = "models"))]
{
let _ = dir;
eprintln!(
"warning: `[paths] model_store` is set but this build lacks the \
`models` feature; the setting has no effect"
);
}
}
#[cfg(feature = "inference-local-models")]
let _backend = rto_llama::backend::SharedBackendGuard::hold();
let _engines = rto_graph::MediaEngineGuard::hold();
match cli.command {
Command::Sync { json, committed } => run_sync(ingest, json, committed),
Command::Check {
json,
committed,
staged,
} => run_check(ingest, json, committed, staged, debt_ignore),
Command::Review { json, base } => run_review(ingest, json, base.as_deref()),
Command::Query {
key,
kind,
app_config_only,
json,
} => run_query(ingest, key, kind, app_config_only, json),
Command::Search { query, limit, json } => run_search(ingest, &query, limit, json),
Command::Context { key, refresh, json } => run_context(ingest, key, refresh, json),
Command::Debt { kind, json } => run_debt(ingest, &kind, json, debt_ignore),
Command::Path { from, to, json } => run_path(ingest, &from, &to, json),
Command::Links {
workspace,
workspace_name,
infer,
matrix,
hub,
hub_rev,
pinned,
write,
html,
out,
app_config_only,
json,
} => {
let pin = PinnedHub {
rev: hub_rev.as_deref(),
auto: pinned,
ingest,
};
let scope = LinksScope {
cli_roots: &workspace,
workspace_name: workspace_name.as_deref(),
};
let opts = InferOptions {
hub: hub.as_deref(),
pin,
app_config_only,
};
if matrix {
run_links_matrix(&cfg.effective, &scope, opts, html, out, json)
} else if infer {
run_links_infer(&cfg.effective, &scope, opts, write, json)
} else {
if app_config_only {
anyhow::bail!(
"`--app-config-only` applies only to `roteiro links --infer` / `--matrix` \
(it filters cross-repo config-key matching); \
`roteiro query --kind config_key --app-config-only` supports it too"
);
}
run_links(&cfg.effective, &scope, json)
}
}
Command::Export { out } => run_export(ingest, out),
Command::Load { file, force } => run_load(&file, force),
Command::Init { fetch, vault } => run_init(ingest, fetch, vault),
Command::Render { target, out } => run_render(ingest, &target, out),
Command::Import { from, path, json } => run_import(ingest, &from, &path, json),
Command::Spec { action } => run_spec(&cfg.effective, ingest, action),
Command::Config { json } => run_config(&cfg, json),
#[cfg(feature = "inference")]
Command::Infer {
min_confidence,
top_k,
model,
json,
} => run_infer(&cfg.effective, ingest, min_confidence, top_k, model, json),
#[cfg(feature = "inference")]
Command::Duplicates {
min_similarity,
limit,
json,
} => run_duplicates(&cfg.effective, ingest, min_similarity, limit, json),
#[cfg(feature = "models")]
Command::Model { action } => run_model(action),
#[cfg(feature = "execution")]
Command::Security { action } => run_security(action),
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
Command::Serve {
models,
http,
addr,
tls_cert,
tls_key,
workspace,
workspace_name,
sync_on_access,
mcp,
} => run_serve(
ingest,
&cfg.effective,
&ServeOptions {
models,
http,
addr,
tls_cert,
tls_key,
mcp,
},
&workspace,
workspace_name.as_deref(),
sync_on_access,
),
#[cfg(any(feature = "mcp", feature = "serve"))]
Command::Mcp {
http,
workspace,
workspace_name,
sync_on_access,
} => run_mcp(
ingest,
&cfg.effective,
http,
&workspace,
workspace_name.as_deref(),
sync_on_access,
),
#[cfg(feature = "explorer")]
Command::Explorer {
addr,
workspace_name,
} => run_explorer(&cfg.effective, addr, workspace_name.as_deref()),
}
}
fn provenance(proj: bool, usr: bool) -> &'static str {
if proj {
"project"
} else if usr {
"user"
} else {
"default"
}
}
fn emit_json<T: serde::Serialize>(value: &T) -> anyhow::Result<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn run_config(loaded: &config::Loaded, json: bool) -> anyhow::Result<()> {
if json {
emit_json(&loaded.effective)?;
return Ok(());
}
println!(
"project config: {}",
loaded
.project_path
.as_deref()
.map_or_else(|| "(none)".to_owned(), |p| p.display().to_string())
);
println!(
"user config: {}",
loaded
.user_path
.as_deref()
.map_or_else(|| "(none)".to_owned(), |p| p.display().to_string())
);
print_config_sections(loaded);
println!("\n(unset values fall back to built-in defaults; a CLI flag overrides config)");
Ok(())
}
fn print_config_sections(loaded: &config::Loaded) {
let source = provenance;
let e = &loaded.effective;
let (p, u) = (&loaded.project, &loaded.user);
println!("\n[models]");
println!(
" embedding = {:?} ({})",
e.models.embedding,
source(p.models.embedding.is_some(), u.models.embedding.is_some())
);
println!(
" generative = {:?} ({})",
e.models.generative,
source(p.models.generative.is_some(), u.models.generative.is_some())
);
println!("[infer]");
println!(
" min_confidence = {:?} ({})",
e.infer.min_confidence,
source(
p.infer.min_confidence.is_some(),
u.infer.min_confidence.is_some()
)
);
println!(
" top_k = {:?} ({})",
e.infer.top_k,
source(p.infer.top_k.is_some(), u.infer.top_k.is_some())
);
println!("[duplicates]");
println!(
" min_similarity = {:?} ({})",
e.duplicates.min_similarity,
source(
p.duplicates.min_similarity.is_some(),
u.duplicates.min_similarity.is_some()
)
);
println!(
" limit = {:?} ({})",
e.duplicates.limit,
source(p.duplicates.limit.is_some(), u.duplicates.limit.is_some())
);
println!("[ingest]");
println!(
" prose = {:?} ({})",
e.ingest.prose,
source(p.ingest.prose.is_some(), u.ingest.prose.is_some())
);
println!(
" pdf = {:?} ({})",
e.ingest.pdf,
source(p.ingest.pdf.is_some(), u.ingest.pdf.is_some())
);
println!(
" ocr = {:?} ({})",
e.ingest.ocr,
source(p.ingest.ocr.is_some(), u.ingest.ocr.is_some())
);
println!(
" vision = {:?} ({})",
e.ingest.vision,
source(p.ingest.vision.is_some(), u.ingest.vision.is_some())
);
println!(
" audio = {:?} ({})",
e.ingest.audio,
source(p.ingest.audio.is_some(), u.ingest.audio.is_some())
);
println!("[serve]");
println!(
" addr = {:?} ({})",
e.serve.addr,
source(p.serve.addr.is_some(), u.serve.addr.is_some())
);
println!(
" models = {:?} ({})",
e.serve.models,
source(p.serve.models.is_some(), u.serve.models.is_some())
);
println!(
" tools = {:?} ({})",
e.serve.tools,
source(p.serve.tools.is_some(), u.serve.tools.is_some())
);
print_telemetry_section(e, p, u);
print_workspace_section(e, p, u);
}
fn print_telemetry_section(e: &config::Config, p: &config::Config, u: &config::Config) {
println!("[telemetry]");
println!(
" file = {:?} ({})",
e.telemetry.file,
provenance(p.telemetry.file.is_some(), u.telemetry.file.is_some())
);
println!(
" rotation = {:?} ({})",
e.telemetry.rotation,
provenance(
p.telemetry.rotation.is_some(),
u.telemetry.rotation.is_some()
)
);
println!(
" format = {:?} ({})",
e.telemetry.format,
provenance(p.telemetry.format.is_some(), u.telemetry.format.is_some())
);
}
fn print_workspace_section(e: &config::Config, p: &config::Config, u: &config::Config) {
println!("[workspace]");
println!(
" roots = {:?} ({})",
e.workspace.roots,
provenance(p.workspace.roots.is_some(), u.workspace.roots.is_some())
);
println!(
" repos = {:?} ({})",
e.workspace.repos,
provenance(p.workspace.repos.is_some(), u.workspace.repos.is_some())
);
if !e.links.is_empty() {
println!(
"[[links]] ({} cross-repo link(s), ADR-0009)",
e.links.len()
);
for l in &e.links {
println!(
" → {} ({})",
l.to,
l.kind.as_deref().unwrap_or("references")
);
}
}
}
fn run_sync(
ingest: rto_graph::IngestConfig,
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 registry = Registry::new(ingest);
let report = if committed_only {
sync(&mut store, &repo, &cache, ®istry)?
} else {
sync_worktree(&mut store, &repo, &cache, ®istry)?
};
if json {
emit_json(&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 read_source(
repo: &rto_graph::Repo,
blob: &rto_graph::BlobRef,
source: GraphSource,
) -> anyhow::Result<Option<Vec<u8>>> {
match source {
GraphSource::Worktree => match repo.workdir() {
Some(workdir) => match std::fs::read(workdir.join(&blob.path)) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
},
None => Ok(Some(repo.read_blob(&blob.oid)?)),
},
GraphSource::Committed | GraphSource::Index => Ok(Some(repo.read_blob(&blob.oid)?)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GraphSource {
Committed,
Worktree,
Index,
}
fn build_graph(
repo: &rto_graph::Repo,
store: &mut rto_graph::Store,
cache: &rto_graph::ObjectCache,
ingest: rto_graph::IngestConfig,
source: GraphSource,
) -> anyhow::Result<rto_spec::CheckReport> {
use rto_graph::{Registry, sync, sync_index, sync_worktree};
let registry = Registry::new(ingest);
match source {
GraphSource::Committed => sync(store, repo, cache, ®istry)?,
GraphSource::Worktree => sync_worktree(store, repo, cache, ®istry)?,
GraphSource::Index => sync_index(store, repo, cache, ®istry)?,
};
let blobs = match source {
GraphSource::Index => repo.index_files()?,
GraphSource::Committed | GraphSource::Worktree => repo.walk_blobs()?,
};
let mut docs = Vec::new();
let mut blueprints = Vec::new();
let mut annotations = Vec::new();
let mut malformed = Vec::new();
for blob in blobs {
let Some(bytes) = read_source(repo, &blob, source)? else {
continue;
};
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 if is_md && rto_spec::is_blueprint(&blob.path, &text) {
blueprints.push(rto_spec::parse_blueprint(&blob.path, &text));
} else {
annotations.extend(rto_spec::scan_annotations(&blob.path, &text));
}
}
let mut report = rto_spec::run(store, &docs, &blueprints, &annotations)?;
report.violations.extend(malformed);
store.reapply_imports()?;
Ok(report)
}
fn run_check(
ingest: rto_graph::IngestConfig,
json: bool,
committed: bool,
staged: bool,
debt_ignore: &[String],
) -> anyhow::Result<()> {
let source = if staged {
GraphSource::Index
} else if committed {
GraphSource::Committed
} else {
GraphSource::Worktree
};
let (repo, mut store, cache) = open_graph()?;
let report = build_graph(&repo, &mut store, &cache, ingest, source)?;
if json {
emit_json(&report)?;
} else {
for v in &report.violations {
eprintln!("drift [{}]: {}", v.kind.label(), v.message);
}
println!(
"checked {} ADR(s), {} blueprint(s): {} link(s) ok, {} annotation(s) ok, {} violation(s)",
report.adrs,
report.blueprints,
report.links_ok,
report.annotations_ok,
report.violations.len(),
);
println!(
"{}",
debt_summary(&rto_graph::debt(&store, &[], debt_ignore)?)
);
}
if report.has_violations() {
exit_gate_failure();
}
Ok(())
}
fn run_review(
ingest: rto_graph::IngestConfig,
json: bool,
base: Option<&str>,
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
let source = if base.is_some() {
GraphSource::Committed
} else {
GraphSource::Worktree
};
let report = build_graph(&repo, &mut store, &cache, ingest, source)?;
let changed =
if let Some(base) = base {
repo.changed_between(base)?
} else {
let mut changed = repo.changed_files()?;
changed.extend(repo.untracked_files()?.into_iter().map(|path| {
rto_graph::ChangedFile {
path,
status: rto_graph::ChangeStatus::Added,
}
}));
changed.sort_by(|a, b| a.path.cmp(&b.path));
changed.dedup_by(|a, b| a.path == b.path);
changed
};
let review = review::build(&store, &changed, &report.violations)?;
if json {
emit_json(&review)?;
} else {
print_review(&review, base);
}
if review.has_drift() {
exit_gate_failure();
}
Ok(())
}
fn print_review(review: &review::ReviewReport, base: Option<&str>) {
if review.changed_files == 0 {
match base {
Some(base) => println!("no changes in {base}..HEAD to review"),
None => println!("no working-tree changes to review"),
}
return;
}
for file in &review.files {
println!("\n{} [{}]", file.path, file.status);
for sym in &file.symbols {
println!(" {} {}", sym.kind, sym.name);
let show = |label: &str, keys: &[String]| {
if !keys.is_empty() {
println!(" {label}: {}", keys.join(", "));
}
};
show("called by", &sym.callers);
show("calls", &sym.callees);
show("governed by", &sym.governed_by);
if !sym.related.is_empty() {
let rel: Vec<String> = sym.related.iter().map(|r| r.node.clone()).collect();
println!(" related: {}", rel.join(", "));
}
}
if !file.debt.is_empty() {
println!(" intent-debt: {}", file.debt.len());
}
}
if !review.impacted.is_empty() {
let names: Vec<&str> = review.impacted.iter().map(|i| i.key.as_str()).collect();
println!("\nimpacted (blast radius): {}", names.join(", "));
}
if review.has_drift() {
println!("\ndrift introduced by this change:");
for d in &review.drift {
println!(" [{}] {}", d.kind, d.message);
}
} else {
println!("\nno authored-layer drift introduced");
}
println!(
"\nreviewed {} changed file(s), {} impacted node(s), {} drift item(s)",
review.changed_files,
review.impacted.len(),
review.drift.len()
);
}
fn run_init(ingest: rto_graph::IngestConfig, fetch: bool, vault: bool) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
let report = build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let nodes = store.node_count()?;
let edges = store.edge_count()?;
let hooks_dir = repo.hooks_dir();
for name in init::MANAGED_HOOKS {
match init::install_hook(&hooks_dir, name, fetch, vault)? {
init::HookOutcome::Installed => println!("installed hook: {name}"),
init::HookOutcome::Updated => println!("refreshed hook: {name}"),
init::HookOutcome::SkippedForeign => {
let advice = if *name == "pre-commit" {
"add `roteiro check` to it to gate drift-introducing commits"
} else {
"add `roteiro sync --committed` to it to keep the graph fresh"
};
eprintln!("warning: existing non-Roteiro `{name}` hook left untouched; {advice}");
}
}
}
if let Some(workdir) = repo.workdir() {
let path = workdir.join("AGENTS.md");
if init::ensure_agents(&path)? {
println!("wrote Roteiro section to {}", path.display());
}
let mut skill_bases = vec![workdir.join(".agents")];
if workdir.join(".github").is_dir() {
skill_bases.push(workdir.join(".github"));
}
for base in &skill_bases {
let full = init::skill_path(base);
let rel = full.strip_prefix(workdir).unwrap_or(&full);
match init::install_skill(base)? {
init::HookOutcome::Installed => println!("installed skill: {}", rel.display()),
init::HookOutcome::Updated => println!("refreshed skill: {}", rel.display()),
init::HookOutcome::SkippedForeign => {
eprintln!(
"warning: existing non-Roteiro `{}` left untouched",
rel.display()
);
}
}
}
}
if vault {
render_obsidian(ingest, None)?;
}
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-local-models")]
fn config_embedding_model(name: Option<&str>) -> Option<String> {
name.map(str::to_owned)
}
#[cfg(all(feature = "inference", not(feature = "inference-local-models")))]
fn config_embedding_model(name: Option<&str>) -> Option<String> {
if let Some(name) = name {
eprintln!(
"warning: config `[models] embedding = {name:?}` needs the \
`inference-local-models` feature; this build ignores it and uses \
the offline default (pass `--model` to force an error instead)"
);
}
None
}
#[cfg(feature = "inference")]
fn run_infer(
cfg: &config::Config,
ingest: rto_graph::IngestConfig,
min_confidence: Option<f64>,
top_k: Option<usize>,
model: Option<String>,
json: bool,
) -> anyhow::Result<()> {
use rto_graph::{FactSet, InferenceConfig};
let min_confidence = min_confidence.or(cfg.infer.min_confidence).unwrap_or(0.4);
let top_k = top_k.or(cfg.infer.top_k).unwrap_or(5);
let model = model.or_else(|| config_embedding_model(cfg.models.embedding.as_deref()));
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, ingest, GraphSource::Committed)?;
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.as_deref())?;
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,
});
emit_json(&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(feature = "inference")]
fn run_duplicates(
cfg: &config::Config,
ingest: rto_graph::IngestConfig,
min_similarity: Option<f64>,
limit: Option<usize>,
json: bool,
) -> anyhow::Result<()> {
use rto_graph::DuplicateConfig;
let min_similarity = min_similarity
.or(cfg.duplicates.min_similarity)
.unwrap_or(0.9);
let limit = limit.or(cfg.duplicates.limit).unwrap_or(50);
if !(0.0..=1.0).contains(&min_similarity) {
anyhow::bail!("--min-similarity must be in 0.0..=1.0 (got {min_similarity})");
}
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let report = rto_graph::duplicates(
&store,
DuplicateConfig {
min_similarity,
limit,
},
)?;
if json {
emit_json(&report)?;
} else if report.pairs.is_empty() {
println!("no duplicate content found (min-similarity {min_similarity})");
} else {
let shown = if report.total > report.pairs.len() {
format!(" (showing top {})", report.pairs.len())
} else {
String::new()
};
println!("{} duplicate pair(s){shown}:", report.total);
for p in &report.pairs {
let tag = if p.exact { "exact" } else { "~sim " };
println!(" [{tag} {:.2}] {} <-> {}", p.similarity, p.a, p.b);
}
}
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, 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`)"))?;
if spec.kind != rto_graph::ModelKind::Embedding {
anyhow::bail!(
"model `{name}` is a {} model, not an embedding model — \
`infer --model` needs an embedding model (see `roteiro model list`)",
spec.kind.as_str()
);
}
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 embedder = LlamaEmbedder::new(name)?;
let edges = infer_edges_with(store, config, &embedder)?;
Ok((edges, format!("local model `{name}` (llama.cpp)")))
}
#[cfg(feature = "inference-local-models")]
struct LlamaEmbedder {
engine: rto_llama::llama::LlamaEngine,
model: String,
}
#[cfg(feature = "inference-local-models")]
impl LlamaEmbedder {
fn new(name: &str) -> anyhow::Result<Self> {
let engine = rto_llama::llama::LlamaEngine::new(
vec![rto_llama::llama::Served {
name: name.to_owned(),
path: rto_graph::model_dir(name).join("model.gguf"),
mmproj: None,
}],
0,
)
.map_err(|e| anyhow::anyhow!("loading model `{name}`: {e}"))?;
Ok(Self {
engine,
model: name.to_owned(),
})
}
}
#[cfg(feature = "inference-local-models")]
impl rto_graph::Embedder for LlamaEmbedder {
fn embed(&self, text: &str) -> Vec<f32> {
use rto_llama::Engine as _;
self.engine
.embed(&self.model, &[text.to_owned()])
.ok()
.and_then(|mut v| v.pop())
.unwrap_or_default()
}
}
#[cfg(feature = "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 = "models")]
fn run_model_list() {
use rto_graph::{ModelKind, Platform, REGISTRY, ResourceTier};
const MARK_INSTALLED: &str = "[installed]";
const MARK_AVAILABLE: &str = "[available]";
const _: () = assert!(MARK_INSTALLED.len() == MARK_AVAILABLE.len());
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)");
let sections = [
(ModelKind::Embedding, "Embedding (`roteiro infer --model`)"),
(ModelKind::Generative, "Generative (`roteiro spec draft`)"),
(
ModelKind::Ocr,
"OCR — image text (`roteiro sync` with --features image-ocr)",
),
(
ModelKind::Vision,
"Vision — image description (`roteiro sync` with --features image-vision)",
),
(
ModelKind::Audio,
"Audio — speech transcription (`roteiro sync` with --features audio-transcribe)",
),
];
let tiers = [
(ResourceTier::Low, "low (any laptop)"),
(ResourceTier::Mid, "mid (~16 GB)"),
(ResourceTier::High, "high (workstation / 64 GB)"),
];
let name_w = REGISTRY.iter().map(|s| s.name.len()).max().unwrap_or(0);
let desc_indent = 4 + MARK_AVAILABLE.len() + 1;
for (kind, heading) in sections {
println!("\n{heading}:");
for (tier, tier_label) in tiers {
let mut specs = REGISTRY
.iter()
.filter(|s| s.kind == kind && s.tier == tier)
.peekable();
if specs.peek().is_none() {
continue;
}
println!(" {tier_label}");
for spec in specs {
let variant = spec.variant_for(host);
let installed = variant.is_some_and(|v| rto_graph::is_installed(spec.name, v));
let mark = if installed {
MARK_INSTALLED
} else {
MARK_AVAILABLE
};
let dim = if spec.dim > 0 {
format!(", dim {}", spec.dim)
} else {
String::new()
};
let role = spec
.role
.as_str()
.map(|r| format!(", {r}"))
.unwrap_or_default();
println!(
" {mark} {name:<name_w$} {licence}{role}{dim}, ~{size} MiB",
name = spec.name,
licence = spec.licence,
size = spec.size_mib,
);
println!("{:desc_indent$}{desc}", "", desc = spec.description);
}
}
}
}
#[cfg(feature = "models")]
fn run_model_pull(name: &str, yes: bool) -> anyhow::Result<()> {
use rto_graph::{Platform, ensure_model_dir, find_model};
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);
if f.sha256.is_empty() {
eprintln!(
" warning: no checksum pinned for {} — integrity NOT verified",
f.name
);
}
let reader = http_reader(f.url)?;
rto_graph::download_verified(reader, &dest, f.sha256)
.map_err(|e| anyhow::anyhow!("downloading {}: {e}", f.name))?;
}
let use_hint = match spec.kind {
rto_graph::ModelKind::Embedding => format!("roteiro infer --model {name}"),
rto_graph::ModelKind::Generative => "roteiro spec draft <topic>".to_owned(),
rto_graph::ModelKind::Ocr => {
"roteiro sync (a build with --features image-ocr OCRs images)".to_owned()
}
rto_graph::ModelKind::Vision => {
"roteiro sync (a build with --features image-vision describes images)".to_owned()
}
rto_graph::ModelKind::Audio => {
"roteiro sync (a build with --features audio-transcribe transcribes audio)".to_owned()
}
};
println!(
"installed `{name}` → {} (use it with `{use_hint}`)",
dir.display()
);
Ok(())
}
#[cfg(feature = "models")]
fn http_reader(url: &str) -> anyhow::Result<impl std::io::Read> {
Ok(ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("GET {url}: {e}"))?
.into_body()
.into_reader())
}
fn run_import(
ingest: rto_graph::IngestConfig,
from: &str,
path: &str,
json: bool,
) -> anyhow::Result<()> {
match from {
"graphify" => run_import_graphify(ingest, path, json),
"lat" => run_import_lat(ingest, path, json),
"codegraph" => run_compare_codegraph(ingest, path, json),
other => {
anyhow::bail!("unknown import source `{other}` (expected: graphify | lat | codegraph)")
}
}
}
fn run_compare_codegraph(
ingest: rto_graph::IngestConfig,
path: &str,
json: bool,
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let report = rto_graph::compare_codegraph(std::path::Path::new(path), &store)?;
if json {
emit_json(&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(ingest: rto_graph::IngestConfig, 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 mut imported = rto_spec::import_lat(&files);
let mut backlinks = Vec::new();
for blob in repo.walk_blobs()? {
let bytes = repo.read_blob(&blob.oid)?;
let text = String::from_utf8_lossy(&bytes);
backlinks.extend(rto_spec::scan_lat_annotations(&blob.path, &text));
}
let (backlink_edges, unresolved) = rto_spec::import_lat_backlinks(&files, &backlinks);
imported.report.backlinks_resolved = backlink_edges.len();
imported.report.backlinks_unresolved = unresolved;
imported.facts.edges.extend(backlink_edges);
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
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);
emit_json(&report)?;
} else {
println!(
"imported lat.md: {} file(s), {} section(s), {} link(s) \
({} to sections, {} to code), {} backlink(s) ({} unresolved); \
{} edge(s) applied, {} stale pruned — persisted (durable)",
r.files,
r.sections,
r.links_total,
r.links_to_sections,
r.links_to_code,
r.backlinks_resolved,
r.backlinks_unresolved,
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(
ingest: rto_graph::IngestConfig,
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, ingest, GraphSource::Committed)?;
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);
emit_json(&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_spec(
cfg: &config::Config,
ingest: rto_graph::IngestConfig,
action: SpecAction,
) -> anyhow::Result<()> {
match action {
SpecAction::Context { topic, limit, json } => run_spec_context(ingest, &topic, limit, json),
SpecAction::Scaffold {
topic,
title,
kind,
out,
} => run_spec_scaffold(ingest, &topic, title.as_deref(), &kind, out.as_deref()),
SpecAction::Draft {
topic,
title,
kind,
out,
} => run_spec_draft(cfg, ingest, &topic, title.as_deref(), &kind, out.as_deref()),
}
}
fn build_scaffold(
ingest: rto_graph::IngestConfig,
topic: &str,
title: Option<&str>,
kind: &str,
) -> anyhow::Result<(String, String, rto_spec::SpecContext)> {
if kind != "adr" && kind != "blueprint" {
anyhow::bail!("unknown --kind `{kind}` (expected: adr | blueprint)");
}
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let root = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("cannot scaffold in a bare repository"))?;
let ctx = rto_spec::context(&store, topic, 10)?;
let (md, label) = if kind == "adr" {
let adr_id = next_adr_id(&root.join("docs/adr"));
(
rto_spec::scaffold_adr(topic, title, &adr_id, &today_utc(), &ctx),
format!("ADR-{adr_id}"),
)
} else {
(
rto_spec::scaffold_blueprint(topic, title, &ctx),
"blueprint".to_owned(),
)
};
Ok((md, label, ctx))
}
fn emit_artifact(md: &str, label: &str, out: Option<&str>) -> anyhow::Result<()> {
match out {
Some(path) => {
std::fs::write(path, md)?;
eprintln!("wrote {label} → {path}");
}
None => print!("{md}"),
}
Ok(())
}
fn run_spec_scaffold(
ingest: rto_graph::IngestConfig,
topic: &str,
title: Option<&str>,
kind: &str,
out: Option<&str>,
) -> anyhow::Result<()> {
let (md, label, _ctx) = build_scaffold(ingest, topic, title, kind)?;
emit_artifact(&md, &format!("{label} scaffold"), out)
}
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
fn run_spec_draft(
cfg: &config::Config,
ingest: rto_graph::IngestConfig,
topic: &str,
title: Option<&str>,
kind: &str,
out: Option<&str>,
) -> anyhow::Result<()> {
use rto_graph::{
ModelKind, ModelRole, Platform, REGISTRY, ResourceTier, find_model, is_installed,
};
let (scaffold, label, ctx) = build_scaffold(ingest, topic, title, kind)?;
let Some(spec) = cfg
.models
.generative
.as_deref()
.and_then(find_model)
.filter(|m| m.kind == ModelKind::Generative)
.or_else(|| {
REGISTRY.iter().find(|m| {
m.kind == ModelKind::Generative
&& m.role == ModelRole::Instruct
&& m.tier == ResourceTier::Low
})
})
else {
anyhow::bail!("no generative model in the registry");
};
let installed = spec
.variant_for(Platform::host())
.is_some_and(|v| is_installed(spec.name, v));
if !installed {
eprintln!(
"note: generative model `{0}` is not installed — emitting the scaffold. \
Draft prose with: roteiro model pull {0}",
spec.name
);
return emit_artifact(&scaffold, &format!("{label} scaffold"), out);
}
if cfg!(debug_assertions) {
eprintln!(
"note: unoptimized build — local generation is very slow; use a \
release build (`cargo build --release`) for usable speed."
);
}
let drafts = draft_sections(spec.name, &scaffold, topic, &ctx)?;
eprintln!(
"drafted {} section(s) with {} (via {GEN_BACKEND})",
drafts.len(),
spec.name
);
let md = rto_spec::apply_drafts(&scaffold, &drafts);
emit_artifact(&md, &format!("{label} draft"), out)
}
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
const GEN_BACKEND: &str = "llama.cpp";
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
const DRAFT_MAX_TOKENS: u32 = 800;
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
fn draft_sections(
model: &str,
scaffold: &str,
topic: &str,
ctx: &rto_spec::SpecContext,
) -> anyhow::Result<Vec<(String, String)>> {
use rto_llama::Engine as _;
let engine = rto_llama::llama::LlamaEngine::new(
vec![rto_llama::llama::Served {
name: model.to_owned(),
path: rto_graph::model_dir(model).join("model.gguf"),
mmproj: None,
}],
0,
)
.map_err(|e| anyhow::anyhow!("starting llama.cpp: {e}"))?;
let mut drafts = Vec::new();
for (heading, hint) in rto_spec::draft_targets(scaffold) {
let prompt = rto_spec::draft_prompt(topic, ctx, &heading, &hint);
let completion = engine
.chat(&rto_llama::ChatRequest {
model: model.to_owned(),
messages: vec![rto_llama::Message {
role: "user".to_owned(),
content: prompt,
}],
images: vec![],
audio: vec![],
temperature: 0.0,
max_tokens: DRAFT_MAX_TOKENS,
})
.map_err(|e| anyhow::anyhow!("drafting `{heading}`: {e}"))?;
let prose = strip_thinking(&completion.content);
if !prose.trim().is_empty() {
drafts.push((heading, prose));
}
}
Ok(drafts)
}
#[cfg(any(feature = "serve", feature = "inference-local-models"))]
fn strip_thinking(text: &str) -> String {
match text.find("</think>") {
Some(end) => text[end + "</think>".len()..].trim_start().to_owned(),
None => text.to_owned(),
}
}
#[cfg(not(any(feature = "serve", feature = "inference-local-models")))]
fn run_spec_draft(
_cfg: &config::Config,
_ingest: rto_graph::IngestConfig,
_topic: &str,
_title: Option<&str>,
_kind: &str,
_out: Option<&str>,
) -> anyhow::Result<()> {
anyhow::bail!(
"`spec draft` needs a generation backend: build with `--features serve` \
or `--features inference-local-models` (both llama.cpp), then \
`roteiro model pull qwen3-0.6b`. (`spec scaffold` works with no model.)"
)
}
fn next_adr_id(adr_dir: &std::path::Path) -> String {
let mut max = 0u32;
if let Ok(entries) = std::fs::read_dir(adr_dir) {
for entry in entries.flatten() {
if let Some(name) = entry.file_name().to_str() {
let digits: String = name.chars().take_while(char::is_ascii_digit).collect();
if let Ok(n) = digits.parse::<u32>() {
max = max.max(n);
}
}
}
}
format!("{:04}", max + 1)
}
fn today_utc() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let days = i64::try_from(secs / 86_400).unwrap_or(0) + 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let doe = days - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = year + i64::from(month <= 2);
format!("{year:04}-{month:02}-{day:02}")
}
fn run_spec_context(
ingest: rto_graph::IngestConfig,
topic: &str,
limit: usize,
json: bool,
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let ctx = rto_spec::context(&store, topic, limit)?;
if json {
emit_json(&ctx)?;
} else {
println!("context for \"{}\":", ctx.topic);
if ctx.symbols.is_empty() && ctx.docs.is_empty() {
println!(" (nothing in the graph matches — try `roteiro query --kind fn` to browse)");
}
if !ctx.symbols.is_empty() {
println!(" symbols:");
for s in &ctx.symbols {
println!(" {} ({})", s.node.key, s.node.kind);
if let Some(c) = &s.container {
println!(" in: {c}");
}
if !s.called_by.is_empty() {
println!(" called by: {}", s.called_by.join(", "));
}
if !s.calls.is_empty() {
println!(" calls: {}", s.calls.join(", "));
}
if !s.authored_by.is_empty() {
println!(" governed by: {}", s.authored_by.join(", "));
}
}
}
if !ctx.docs.is_empty() {
println!(" docs:");
for d in &ctx.docs {
println!(" {} {}", d.key, d.name);
}
}
if !ctx.related_adrs.is_empty() {
println!(" related ADRs: {}", ctx.related_adrs.join(", "));
}
}
Ok(())
}
fn cfgkey_file(node_key: &str) -> Option<&str> {
node_key
.strip_prefix("cfgkey:")
.map(|rest| rest.split_once('#').map_or(rest, |(file, _)| file))
}
fn run_query(
ingest: rto_graph::IngestConfig,
key: Option<String>,
kind: Option<String>,
app_config_only: bool,
json: bool,
) -> anyhow::Result<()> {
use rto_graph::{NodeKind, explain, list_kind};
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
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 {
emit_json(&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 mut listing = list_kind(&store, &NodeKind::from_token(&kind))?;
if app_config_only {
listing.nodes.retain(|n| match cfgkey_file(&n.key) {
Some(file) => !rto_graph::is_tooling_config_path(file),
None => true,
});
}
if json {
emit_json(&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_search(
ingest: rto_graph::IngestConfig,
query: &str,
limit: usize,
json: bool,
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let hits = rto_graph::search(&store, query, limit)?;
if json {
emit_json(&hits)?;
} else if hits.is_empty() {
eprintln!("no matches for `{query}`");
} else {
for hit in &hits {
println!(" {:>4} {:<8} {}", hit.score, hit.node.kind, hit.node.key);
}
println!("{} hit(s)", hits.len());
}
Ok(())
}
fn run_context(
ingest: rto_graph::IngestConfig,
key: Option<String>,
refresh: bool,
json: bool,
) -> anyhow::Result<()> {
use rto_graph::{context, refresh_contexts};
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
if refresh {
let report = refresh_contexts(&store)?;
if json {
emit_json(&report)?;
} else {
println!(
"context cache refreshed: {} rebuilt, {} reused, {} pruned",
report.rebuilt, report.reused, report.pruned
);
}
return Ok(());
}
let Some(key) = key else {
anyhow::bail!("provide a node key, or `--refresh` to refresh all cached contexts");
};
let Some(ctx) = context(&store, &key)? else {
anyhow::bail!("no node with key `{key}` (try `roteiro query --kind <kind>` to list nodes)");
};
if json {
emit_json(&ctx)?;
} else {
println!("{} ({}) {}", ctx.node.key, ctx.node.kind, ctx.node.name);
println!(" fingerprint: {}", ctx.fingerprint);
if !ctx.outgoing.is_empty() {
println!(" outgoing:");
for e in &ctx.outgoing {
println!(" -[{}/{}]-> {}", e.kind, e.provenance, e.node);
}
}
if !ctx.incoming.is_empty() {
println!(" incoming:");
for e in &ctx.incoming {
println!(" <-[{}/{}]- {}", e.kind, e.provenance, e.node);
}
}
}
Ok(())
}
fn run_debt(
ingest: rto_graph::IngestConfig,
kinds: &[String],
json: bool,
debt_ignore: &[String],
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let report = rto_graph::debt(&store, kinds, debt_ignore)?;
if json {
emit_json(&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(
ingest: rto_graph::IngestConfig,
from: &str,
to: &str,
json: bool,
) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let result = rto_graph::path(&store, from, to)?;
if json {
emit_json(&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 {
exit_gate_failure();
}
Ok(())
}
#[derive(serde::Serialize)]
struct LinkResult {
repo: String,
from: Option<String>,
to: String,
kind: String,
status: &'static str,
detail: String,
}
fn workspace_project_names(paths: &[std::path::PathBuf]) -> Vec<(&std::path::PathBuf, String)> {
use std::collections::HashMap;
let mut counts: HashMap<String, usize> = HashMap::new();
paths
.iter()
.map(|p| {
let base = p
.file_name()
.map_or_else(|| "repo".to_owned(), |s| s.to_string_lossy().into_owned());
let n = counts.entry(base.clone()).or_insert(0);
*n += 1;
let name = if *n == 1 { base } else { format!("{base}-{n}") };
(p, name)
})
.collect()
}
struct LinksScope<'a> {
cli_roots: &'a [String],
workspace_name: Option<&'a str>,
}
fn cwd_repo_workdir() -> Option<std::path::PathBuf> {
let cwd = std::env::current_dir().ok()?;
let repo = rto_graph::Repo::discover(&cwd).ok()?;
let wd = repo.workdir()?.to_path_buf();
Some(wd.canonicalize().unwrap_or(wd))
}
fn workspace_containing_cwd<'a>(
resolved: &'a [rto_graph::ResolvedWorkspace],
cwd_wd: &std::path::Path,
) -> Option<&'a rto_graph::ResolvedWorkspace> {
let canon = |p: &std::path::Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
let is_cwd = |p: &std::path::Path| canon(p).as_path() == cwd_wd;
resolved.iter().find(|rw| {
let in_root = rw.roots.iter().any(|root| {
rto_graph::discover_repos_under(std::path::Path::new(root))
.is_ok_and(|repos| repos.iter().any(|r| is_cwd(r)))
});
in_root
|| rw
.repos
.iter()
.any(|repo| is_cwd(std::path::Path::new(repo)))
})
}
fn links_scope_paths(
cfg: &config::Config,
scope: &LinksScope<'_>,
) -> anyhow::Result<Vec<std::path::PathBuf>> {
use std::collections::BTreeSet;
let cli_roots = scope.cli_roots;
let resolved = cfg.resolved_workspaces()?;
let chosen: Option<&rto_graph::ResolvedWorkspace> = if let Some(name) = scope.workspace_name {
Some(resolved.iter().find(|r| r.name == name).ok_or_else(|| {
let known = resolved
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(", ");
anyhow::anyhow!("no workspace named `{name}` (known: {known})")
})?)
} else if resolved.is_empty() {
None
} else {
cwd_repo_workdir().and_then(|wd| workspace_containing_cwd(&resolved, &wd))
};
let mut paths: BTreeSet<std::path::PathBuf> = BTreeSet::new();
match chosen {
Some(rw) => {
for root in rw
.roots
.iter()
.map(String::as_str)
.chain(cli_roots.iter().map(String::as_str))
{
paths.extend(rto_graph::discover_repos_under(std::path::Path::new(root))?);
}
for repo in &rw.repos {
paths.insert(std::path::PathBuf::from(repo));
}
}
None => paths.extend(collect_workspace_repo_paths(&cfg.workspace, cli_roots)?),
}
if let Ok(cwd) = std::env::current_dir()
&& let Ok(repo) = rto_graph::Repo::discover(&cwd)
&& let Some(wd) = repo.workdir()
{
paths.insert(wd.to_path_buf());
}
Ok(paths.into_iter().collect())
}
fn run_links(cfg: &config::Config, scope: &LinksScope<'_>, json: bool) -> anyhow::Result<()> {
let paths = links_scope_paths(cfg, scope)?;
if paths.is_empty() {
anyhow::bail!(
"no repos in scope — run inside a repo, pass `--workspace <root>`, or set \
`[workspace]` in roteiro.toml"
);
}
let workspace = rto_graph::Workspace::from_repo_paths(&paths)?;
let mut results: Vec<LinkResult> = Vec::new();
for (path, repo_name) in workspace_project_names(&paths) {
let repo_cfg = config::load(path)?.effective;
for link in &repo_cfg.links {
let kind = link.kind.clone().unwrap_or_else(|| "references".to_owned());
let (status, detail) = match workspace.resolve_qualified(&link.to) {
Ok(Some(node)) => ("ok", format!("{} {}", node.kind.as_str(), node.name)),
Ok(None) => ("drift", "no such node in the target project".to_owned()),
Err(e) => ("drift", e.to_string()),
};
results.push(LinkResult {
repo: repo_name.clone(),
from: link.from.clone(),
to: link.to.clone(),
kind,
status,
detail,
});
}
}
let drift = results.iter().filter(|r| r.status == "drift").count();
if json {
emit_json(&results)?;
} else if results.is_empty() {
println!(
"no cross-repo links declared across {} repo(s) (add `[[links]]` to a repo's roteiro.toml)",
paths.len()
);
} else {
for r in &results {
let marker = if r.status == "ok" { "ok " } else { "DRIFT" };
println!(" [{marker}] {} → {} ({})", r.repo, r.to, r.detail);
}
println!(
"{} link(s) across {} repo(s): {} ok, {} drift",
results.len(),
paths.len(),
results.len() - drift,
drift
);
}
if drift > 0 {
exit_gate_failure();
}
Ok(())
}
#[derive(serde::Serialize)]
struct InferredRepo {
repo: String,
matches: Vec<infer_links::KeyMatch>,
orphans: Vec<infer_links::ConfigKey>,
#[serde(skip_serializing_if = "Option::is_none")]
hub_rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pin_via: Option<String>,
}
fn graph_db_path(path: &std::path::Path) -> anyhow::Result<std::path::PathBuf> {
let repo = rto_graph::Repo::discover(path)?;
Ok(repo.git_dir().join("roteiro").join("graph.db"))
}
type WorkspaceConfigKeys = (
std::collections::BTreeMap<String, Vec<infer_links::ConfigKey>>,
std::collections::BTreeMap<String, std::path::PathBuf>,
Vec<String>,
);
fn collect_workspace_config_keys(
paths: &[std::path::PathBuf],
) -> anyhow::Result<WorkspaceConfigKeys> {
let mut by_project = std::collections::BTreeMap::new();
let mut project_paths = std::collections::BTreeMap::new();
let mut unsynced = Vec::new();
for (path, name) in workspace_project_names(paths) {
project_paths.insert(name.clone(), path.clone());
let db = graph_db_path(path)?;
if !db.exists() {
unsynced.push(name);
continue;
}
let keys = rto_graph::Store::open(&db)?.config_keys()?;
if !keys.is_empty() {
by_project.insert(name, keys);
}
}
Ok((by_project, project_paths, unsynced))
}
fn retain_app_config_keys(
by_project: &mut std::collections::BTreeMap<String, Vec<infer_links::ConfigKey>>,
) {
for keys in by_project.values_mut() {
keys.retain(|k| !rto_graph::is_tooling_config_path(&k.file));
}
by_project.retain(|_, keys| !keys.is_empty());
}
enum InferScan {
Nothing(String),
Ready(InferReady),
}
struct InferReady {
hub_name: String,
hub_rev: Option<String>,
report: Vec<InferredRepo>,
by_project: std::collections::BTreeMap<String, Vec<infer_links::ConfigKey>>,
project_paths: std::collections::BTreeMap<String, std::path::PathBuf>,
}
#[derive(Clone, Copy)]
struct PinnedHub<'a> {
rev: Option<&'a str>,
auto: bool,
ingest: rto_graph::IngestConfig,
}
#[derive(Clone, Copy)]
struct InferOptions<'a> {
hub: Option<&'a str>,
pin: PinnedHub<'a>,
app_config_only: bool,
}
fn config_keys_at_rev(
repo_path: &std::path::Path,
rev: &str,
ingest: rto_graph::IngestConfig,
) -> anyhow::Result<Vec<infer_links::ConfigKey>> {
let repo = rto_graph::Repo::discover(repo_path)?;
let cache = rto_graph::ObjectCache::open(repo.common_dir().join("roteiro").join("objects"))?;
let reg = rto_graph::Registry::new(ingest);
config_keys_at_rev_with(&repo, &cache, reg, rev)
}
fn config_keys_at_rev_with(
repo: &rto_graph::Repo,
cache: &rto_graph::ObjectCache,
reg: rto_graph::Registry,
rev: &str,
) -> anyhow::Result<Vec<infer_links::ConfigKey>> {
if let Some(keys) = config_keys_from_artifact(repo, rev)? {
return Ok(keys);
}
let mut store = rto_graph::Store::open_in_memory()?;
rto_graph::sync_tree(&mut store, repo, cache, ®, rev)?;
Ok(store.config_keys()?)
}
fn config_keys_from_artifact(
repo: &rto_graph::Repo,
rev: &str,
) -> anyhow::Result<Option<Vec<infer_links::ConfigKey>>> {
let tree = repo.tree_id_at(rev)?;
let path = repo
.common_dir()
.join("roteiro")
.join("artifacts")
.join(format!("{tree}.json"));
let Ok(json) = std::fs::read_to_string(&path) else {
return Ok(None);
};
let Ok(artifact) = rto_graph::GraphArtifact::from_json(&json) else {
return Ok(None);
};
if artifact.tree.as_deref() != Some(tree.as_str()) {
return Ok(None);
}
let mut store = rto_graph::Store::open_in_memory()?;
if store.rebuild(&artifact.facts, None).is_err() {
return Ok(None);
}
Ok(Some(store.config_keys()?))
}
fn scan_workspace_infer(
cfg: &config::Config,
scope: &LinksScope<'_>,
opts: InferOptions<'_>,
) -> anyhow::Result<InferScan> {
let InferOptions {
hub,
pin,
app_config_only,
} = opts;
let paths = links_scope_paths(cfg, scope)?;
if paths.is_empty() {
return Ok(InferScan::Nothing(
"no repos in scope; run inside a repo, pass `--workspace <root>`, or set `[workspace]`"
.to_owned(),
));
}
let (mut by_project, project_paths, unsynced) = collect_workspace_config_keys(&paths)?;
if app_config_only {
retain_app_config_keys(&mut by_project);
}
if by_project.len() < 2 {
let hint = if unsynced.is_empty() {
String::new()
} else {
format!(
" ({} repo(s) not synced: {})",
unsynced.len(),
unsynced.join(", ")
)
};
return Ok(InferScan::Nothing(format!(
"need at least two synced repos with config files (TOML / JSON / .env) — found {}{hint}",
by_project.len()
)));
}
let hub_name = match hub {
Some(h) => {
if !by_project.contains_key(h) {
anyhow::bail!(
"no repo named `{h}` with config (have: {})",
by_project.keys().cloned().collect::<Vec<_>>().join(", ")
);
}
h.to_owned()
}
None => by_project
.iter()
.max_by_key(|(_, v)| v.len())
.map(|(k, _)| k.clone())
.expect("non-empty"),
};
if let Some(rev) = pin.rev {
let hub_path = project_paths
.get(&hub_name)
.ok_or_else(|| anyhow::anyhow!("no path for hub `{hub_name}`"))?;
let mut keys = config_keys_at_rev(hub_path, rev, pin.ingest)
.map_err(|e| anyhow::anyhow!("resolving hub `{hub_name}` at `{rev}`: {e}"))?;
if app_config_only {
keys.retain(|k| !rto_graph::is_tooling_config_path(&k.file));
}
by_project.insert(hub_name.clone(), keys);
}
let report = resolve_infer_report(&by_project, &hub_name, &project_paths, pin)?;
Ok(InferScan::Ready(InferReady {
hub_name,
hub_rev: pin.rev.map(str::to_owned),
report,
by_project,
project_paths,
}))
}
fn detect_spoke_pin(
spoke_path: &std::path::Path,
hub_dir: &str,
hub_origin: Option<&str>,
hub_repo: &rto_graph::Repo,
) -> anyhow::Result<Option<pins::SpokePin>> {
let db = graph_db_path(spoke_path)?;
if !db.exists() {
return Ok(None);
}
let store = rto_graph::Store::open(&db)?;
let templates = config::load(spoke_path)?.effective.pins;
pins::detect(&store, hub_dir, hub_origin, hub_repo, &templates)
}
fn resolve_infer_report(
by_project: &std::collections::BTreeMap<String, Vec<infer_links::ConfigKey>>,
hub_name: &str,
project_paths: &std::collections::BTreeMap<String, std::path::PathBuf>,
pin: PinnedHub<'_>,
) -> anyhow::Result<Vec<InferredRepo>> {
let hub_base = by_project[hub_name].as_slice();
let hub = if pin.auto {
let hub_path = project_paths
.get(hub_name)
.ok_or_else(|| anyhow::anyhow!("no path for hub `{hub_name}`"))?;
let repo = rto_graph::Repo::discover(hub_path)?;
let cache =
rto_graph::ObjectCache::open(repo.common_dir().join("roteiro").join("objects"))?;
let reg = rto_graph::Registry::new(pin.ingest);
let dir = hub_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(hub_name)
.to_owned();
let origin = repo.origin_url();
Some((repo, cache, reg, dir, origin))
} else {
None
};
let mut rev_cache: std::collections::BTreeMap<String, Vec<infer_links::ConfigKey>> =
std::collections::BTreeMap::new();
let mut report = Vec::new();
for (name, keys) in by_project.iter().filter(|(n, _)| n.as_str() != hub_name) {
let (hub_rev, pin_via) = match &hub {
Some((repo, cache, reg, dir, origin)) => {
match detect_spoke_pin(&project_paths[name], dir, origin.as_deref(), repo)? {
Some(p) => {
if !rev_cache.contains_key(&p.rev) {
let k = config_keys_at_rev_with(repo, cache, *reg, &p.rev).map_err(
|e| {
anyhow::anyhow!(
"resolving hub `{hub_name}` at `{}`: {e}",
p.rev
)
},
)?;
rev_cache.insert(p.rev.clone(), k);
}
(Some(p.rev), Some(p.via))
}
None => (None, None),
}
}
None => (pin.rev.map(str::to_owned), None),
};
let hub_keys: &[infer_links::ConfigKey] = match &hub_rev {
Some(rev) if pin.auto => rev_cache[rev].as_slice(),
_ => hub_base,
};
let (matches, orphans) = infer_links::match_against_hub(keys, hub_keys);
report.push(InferredRepo {
repo: name.clone(),
matches,
orphans,
hub_rev,
pin_via,
});
}
Ok(report)
}
fn run_links_infer(
cfg: &config::Config,
scope: &LinksScope<'_>,
opts: InferOptions<'_>,
write: bool,
json: bool,
) -> anyhow::Result<()> {
let ready = match scan_workspace_infer(cfg, scope, opts)? {
InferScan::Nothing(reason) => {
if json {
emit_json(&serde_json::json!({ "hub": null, "spokes": [], "note": reason }))?;
} else {
eprintln!("nothing to infer — {reason}");
}
return Ok(());
}
InferScan::Ready(r) => r,
};
let hub_key_count = ready.by_project[&ready.hub_name].len();
let written = if write {
persist_inferred_links(&ready.hub_name, &ready.report, &ready.project_paths)?
} else {
0
};
if json {
emit_json(&serde_json::json!({
"hub": ready.hub_name,
"hub_rev": ready.hub_rev,
"spokes": ready.report,
"written": written,
}))?;
} else {
if let Some(rev) = &ready.hub_rev {
println!(
"resolved against {} @ {rev} (pinned version)",
ready.hub_name
);
}
print_infer_report(&ready.report, &ready.hub_name, hub_key_count);
if write {
println!("\npersisted {written} inferred cross-repo edge(s) into spoke graphs");
}
}
Ok(())
}
fn run_links_matrix(
cfg: &config::Config,
scope: &LinksScope<'_>,
opts: InferOptions<'_>,
html: bool,
out: Option<String>,
json: bool,
) -> anyhow::Result<()> {
let ready = match scan_workspace_infer(cfg, scope, opts)? {
InferScan::Nothing(reason) => {
if json {
emit_json(
&serde_json::json!({ "hub": null, "rows": [], "drift": [], "note": reason }),
)?;
} else {
eprintln!("nothing to show — {reason}");
}
return Ok(());
}
InferScan::Ready(r) => r,
};
let hub_values: std::collections::BTreeMap<String, String> = ready.by_project[&ready.hub_name]
.iter()
.map(|c| (c.key.clone(), c.value.clone()))
.collect();
let spokes = ready
.report
.iter()
.map(|rep| {
let vals: std::collections::HashMap<(&str, &str), &str> = ready.by_project[&rep.repo]
.iter()
.map(|c| ((c.file.as_str(), c.key.as_str()), c.value.as_str()))
.collect();
overview::SpokeInput {
name: rep.repo.clone(),
matches: rep
.matches
.iter()
.map(|m| overview::MatchInput {
hub_key: m.hub_key.clone(),
file: m.hub_file.clone(),
spoke_key: m.spoke_key.clone(),
spoke_value: vals
.get(&(m.spoke_file.as_str(), m.spoke_key.as_str()))
.copied()
.unwrap_or("")
.to_owned(),
confidence: m.confidence,
provenance: rto_graph::Provenance::Inferred,
})
.collect(),
orphans: rep
.orphans
.iter()
.map(|o| (o.key.clone(), o.value.clone()))
.collect(),
}
})
.collect();
let hub_label = match &ready.hub_rev {
Some(rev) => format!("{} @ {rev}", ready.hub_name),
None => ready.hub_name.clone(),
};
let matrix = overview::build(&hub_label, &hub_values, spokes);
if json {
emit_json(&matrix)?;
} else if html {
let page = overview::render_html(&matrix);
let out = out.unwrap_or_else(|| "roteiro-overview.html".to_owned());
if out == "-" {
println!("{page}");
} else {
std::fs::write(&out, page)?;
eprintln!(
"wrote override matrix ({} row(s), {} drift) → {out}",
matrix.rows.len(),
matrix.drift.len()
);
}
} else {
print!("{}", overview::render_text(&matrix));
}
Ok(())
}
fn persist_inferred_links(
hub_name: &str,
report: &[InferredRepo],
project_paths: &std::collections::BTreeMap<String, std::path::PathBuf>,
) -> anyhow::Result<usize> {
let mut written = 0usize;
for spoke in report {
let facts = infer_links::link_facts(hub_name, &spoke.matches);
let path = project_paths
.get(&spoke.repo)
.ok_or_else(|| anyhow::anyhow!("no path for spoke `{}`", spoke.repo))?;
let db = graph_db_path(path)?;
if !db.exists() {
continue; }
let mut store = rto_graph::Store::open(&db)?;
let applied = store.apply_import_layer(rto_graph::LINKS_REF, &facts)?;
written += applied.edges_applied;
}
Ok(written)
}
fn short_rev(rev: &str) -> &str {
if rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()) {
&rev[..10]
} else {
rev
}
}
fn print_infer_report(report: &[InferredRepo], hub_name: &str, hub_keys: usize) {
println!("inferred config links (hub: {hub_name}, {hub_keys} keys)");
let (mut nm, mut no) = (0usize, 0usize);
for r in report {
let pin = match (&r.hub_rev, &r.pin_via) {
(Some(rev), Some(via)) => format!(" @ {} (via {via})", short_rev(rev)),
(Some(rev), None) => format!(" @ {}", short_rev(rev)),
_ => String::new(),
};
println!(
"\n {} — {} match(es), {} orphan(s){pin}",
r.repo,
r.matches.len(),
r.orphans.len()
);
for m in &r.matches {
println!(
" {:<28} ~ {hub_name}::{:<24} ({:.2})",
m.spoke_key, m.hub_key, m.confidence
);
nm += 1;
}
for o in &r.orphans {
println!(
" {:<28} orphan — no {hub_name} counterpart (drift?)",
o.key
);
no += 1;
}
}
println!(
"\n{nm} match(es), {no} orphan(s) across {} spoke(s)",
report.len()
);
}
fn run_export(ingest: rto_graph::IngestConfig, out: Option<String>) -> anyhow::Result<()> {
use rto_graph::GraphArtifact;
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
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, force: bool) -> 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)?;
if !force {
let head = repo.head_tree_id()?;
let short = |t: &str| t[..t.len().min(12)].to_owned();
match artifact.tree.as_deref() {
Some(tree) if tree == head => {}
Some(tree) => anyhow::bail!(
"artifact tree {} does not match HEAD tree {} — refusing to load a mismatched graph (pass --force to override, or run `roteiro sync`)",
short(tree),
short(&head)
),
None => anyhow::bail!(
"artifact records no tree, so it cannot be verified against HEAD — pass --force to load it anyway, or run `roteiro sync`"
),
}
}
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 = "execution")]
fn run_security(action: SecurityAction) -> anyhow::Result<()> {
match action {
SecurityAction::Ingest { file, json } => run_security_ingest(&file, json),
SecurityAction::List { analyzer, json } => run_security_list(analyzer.as_deref(), json),
}
}
#[cfg(feature = "execution")]
#[derive(serde::Serialize)]
struct SecurityIngestReport {
layer: String,
analyzer: String,
analyzer_version: String,
runner: rto_graph::RunnerKind,
isolation: rto_graph::Isolation,
findings: usize,
removed: usize,
replaced: bool,
report_digest: String,
}
#[cfg(feature = "execution")]
#[derive(serde::Serialize)]
struct SecurityListing {
layers: Vec<rto_graph::FindingsLayer>,
findings: usize,
}
#[cfg(feature = "execution")]
#[derive(serde::Deserialize)]
struct AnalyzerPeek {
analyzer: String,
}
#[cfg(feature = "execution")]
fn report_analyzer(bytes: &[u8], source: &str) -> anyhow::Result<String> {
let peek: AnalyzerPeek = serde_json::from_slice(bytes)
.map_err(|e| anyhow::anyhow!("{source} is not a normalized analyzer report: {e}"))?;
Ok(peek.analyzer)
}
#[cfg(feature = "execution")]
fn run_security_ingest(file: &str, json: bool) -> anyhow::Result<()> {
use rto_exec::{AnalysisRequest, AnalyzerRunner, Consent, IngestRunner, Worktree};
let bytes = if file == "-" {
let mut buf = Vec::new();
std::io::Read::read_to_end(&mut std::io::stdin(), &mut buf)?;
buf
} else {
std::fs::read(file)?
};
let analyzer = report_analyzer(&bytes, file)?;
let (repo, mut store, _cache) = open_graph()?;
let worktree_path = repo
.workdir()
.unwrap_or_else(|| repo.git_dir())
.to_path_buf();
let request = AnalysisRequest {
analyzer,
worktree: Worktree::read_only(&worktree_path)?,
network: rto_graph::NetworkPolicy::Deny,
consent: Consent::Granted,
source: rto_graph::SourceIdentity {
commit: repo.head_commit_id().ok(),
tree: repo.head_tree_id().ok(),
lockfile_blob: None,
},
};
let response = IngestRunner::new(bytes).run(&request)?;
let applied = store.replace_findings_layer(&response.run, &response.findings)?;
if json {
emit_json(&SecurityIngestReport {
layer: applied.layer,
analyzer: response.run.analyzer,
analyzer_version: response.run.analyzer_version,
runner: response.run.runner,
isolation: response.run.isolation,
findings: applied.findings,
removed: applied.removed,
replaced: applied.replaced,
report_digest: response.run.report_digest,
})?;
} else {
let digest = &response.run.report_digest[..12];
println!(
"ingested {} finding(s) from {} {} → {} (runner {}, isolation {}, report {digest}…)",
applied.findings,
response.run.analyzer,
response.run.analyzer_version,
applied.layer,
response.run.runner.as_str(),
response.run.isolation.as_str(),
);
if applied.replaced {
println!(
"replaced the previous layer: {} finding(s) removed, {} now live",
applied.removed, applied.findings
);
}
if let Some(db) = &response.run.advisory_db {
let published = db.published_at.as_deref().unwrap_or("unknown");
println!(
"advisory db {} published {published} — results are as current as that database, no more",
db.digest
);
}
}
Ok(())
}
#[cfg(feature = "execution")]
fn run_security_list(analyzer: Option<&str>, json: bool) -> anyhow::Result<()> {
let (_repo, store, _cache) = open_graph()?;
let layers = store.findings_layers(analyzer)?;
let total: usize = layers.iter().map(|l| l.findings.len()).sum();
if json {
emit_json(&SecurityListing {
layers,
findings: total,
})?;
return Ok(());
}
if layers.is_empty() {
match analyzer {
Some(name) => println!("no findings ingested for `{name}`"),
None => println!("no findings ingested (`roteiro security ingest <report.json>`)"),
}
return Ok(());
}
for layer in &layers {
println!(
"{} — {} {} ({}, isolation {}), {} finding(s)",
layer.run.layer,
layer.run.analyzer,
layer.run.analyzer_version,
layer.run.runner.as_str(),
layer.run.isolation.as_str(),
layer.findings.len(),
);
for finding in &layer.findings {
let where_ = finding.path.as_deref().unwrap_or("-");
println!(
" {:<8} {:<24} {where_} {}",
finding.severity.as_str(),
finding.rule,
finding.title
);
}
}
println!("{total} finding(s) across {} layer(s)", layers.len());
Ok(())
}
#[cfg(feature = "explorer")]
fn run_explorer(
cfg: &config::Config,
addr: Option<String>,
workspace_name: Option<&str>,
) -> anyhow::Result<()> {
use std::sync::Arc;
let resolved = cfg.resolved_workspaces()?;
let set = if resolved.is_empty() {
explorer_cwd_set()?
} else {
rto_graph::WorkspaceSet::from_resolved(resolved)?
};
if set.names().is_empty() {
anyhow::bail!(
"no workspaces to serve — run inside a repo, or configure \
`[[workspaces]]` / `[standalone]` in roteiro.toml"
);
}
let set = Arc::new(set);
if let Some(name) = workspace_name {
set.select(Some(name))?;
}
let default = explorer_default_workspace(&set, workspace_name);
serve_graph_ui(cfg, "explorer", set, default, addr)
}
#[cfg(feature = "explorer")]
fn serve_graph_ui(
cfg: &config::Config,
cmd: &'static str,
set: std::sync::Arc<rto_graph::WorkspaceSet>,
default: Option<String>,
addr: Option<String>,
) -> anyhow::Result<()> {
let addr = addr
.or_else(|| cfg.serve.addr.clone())
.unwrap_or_else(|| "127.0.0.1:8017".to_owned());
let socket: std::net::SocketAddr = addr
.parse()
.map_err(|e| anyhow::anyhow!("invalid {cmd} address `{addr}`: {e}"))?;
if !socket.ip().is_loopback() {
eprintln!(
"warning: binding a non-loopback address ({socket}) — the graph API \
has no auth; front it with a reverse proxy"
);
}
let router = graph_api::router(set.clone(), default.clone()).merge(explorer_app::router());
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(async move {
let listener = tokio::net::TcpListener::bind(socket).await?;
let default_note = default
.as_deref()
.map_or_else(String::new, |d| format!(" (default workspace: {d})"));
eprintln!(
"roteiro {cmd} listening on http://{socket}/ (UI) — \
API at http://{socket}/v1/graph — {} workspace(s): {}{default_note}",
set.names().len(),
set.names().join(", "),
);
axum::serve(listener, router)
.await
.map_err(anyhow::Error::from)
})
}
#[cfg(feature = "explorer")]
fn explorer_cwd_set() -> anyhow::Result<rto_graph::WorkspaceSet> {
let cwd = std::env::current_dir()?;
let repo = rto_graph::Repo::discover(&cwd)?;
let workdir = repo.workdir().unwrap_or(&cwd);
let name = workdir
.file_name()
.map_or_else(|| "repo".to_owned(), |s| s.to_string_lossy().into_owned());
let ws = rto_graph::Workspace::from_repo_paths([workdir])?;
Ok(rto_graph::WorkspaceSet::from_workspaces([(
name, ws, false,
)]))
}
#[cfg(feature = "explorer")]
fn explorer_default_workspace(
set: &rto_graph::WorkspaceSet,
workspace_name: Option<&str>,
) -> Option<String> {
if let Some(name) = workspace_name {
return Some(name.to_owned());
}
let db = graph_db_path(&std::env::current_dir().ok()?).ok()?;
set.containing(&db).map(str::to_owned)
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
#[cfg_attr(not(feature = "serve"), allow(dead_code))]
struct ServeOptions {
models: bool,
http: Option<String>,
addr: Option<String>,
tls_cert: Option<String>,
tls_key: Option<String>,
mcp: bool,
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
#[cfg_attr(not(any(feature = "mcp", feature = "serve")), allow(dead_code))]
#[derive(Debug, PartialEq, Eq)]
enum ServerRoute {
Network,
McpStdio,
McpHttp(String),
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
fn route_serve(http: Option<String>) -> ServerRoute {
match http {
Some(addr) => ServerRoute::McpHttp(addr),
None => ServerRoute::Network,
}
}
#[cfg(any(feature = "mcp", feature = "serve"))]
fn route_mcp(http: Option<String>) -> ServerRoute {
match http {
Some(addr) => ServerRoute::McpHttp(addr),
None => ServerRoute::McpStdio,
}
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
fn serve_deprecation_notice(models: bool, http: Option<&str>) -> Option<String> {
if let Some(addr) = http {
Some(format!(
"note: `roteiro serve --http <ADDR>` is deprecated; use `roteiro mcp --http {addr}`"
))
} else if models {
Some(
"note: `roteiro serve --models` is now the default — the `--models` flag is redundant"
.to_owned(),
)
} else {
None
}
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
fn run_serve(
ingest: rto_graph::IngestConfig,
cfg: &config::Config,
opts: &ServeOptions,
workspace_roots: &[String],
workspace_name: Option<&str>,
sync_on_access: bool,
) -> anyhow::Result<()> {
if let Some(notice) = serve_deprecation_notice(opts.models, opts.http.as_deref()) {
eprintln!("{notice}");
}
match route_serve(opts.http.clone()) {
ServerRoute::Network => {
let ws = build_serve_workspaces(
ingest,
cfg,
"serve",
workspace_roots,
workspace_name,
sync_on_access,
)?;
run_serve_network(cfg, ws, workspace_name, opts)
}
ServerRoute::McpHttp(addr) => {
#[cfg(any(feature = "mcp", feature = "serve"))]
{
let ws = build_serve_workspaces(
ingest,
cfg,
"mcp",
workspace_roots,
workspace_name,
sync_on_access,
)?;
serve_mcp(ws.flat, Some(addr))
}
#[cfg(not(any(feature = "mcp", feature = "serve")))]
{
let _ = (ingest, workspace_roots, sync_on_access);
anyhow::bail!(
"`roteiro serve --http {addr}` (MCP over HTTP) needs the `mcp` feature — \
this build has only the llama-free graph server; rebuild with `--features mcp`"
)
}
}
ServerRoute::McpStdio => unreachable!("`roteiro serve` never routes to STDIO MCP"),
}
}
#[cfg(any(feature = "mcp", feature = "serve"))]
fn run_mcp(
ingest: rto_graph::IngestConfig,
cfg: &config::Config,
http: Option<String>,
workspace_roots: &[String],
workspace_name: Option<&str>,
sync_on_access: bool,
) -> anyhow::Result<()> {
let ws = build_serve_workspaces(
ingest,
cfg,
"mcp",
workspace_roots,
workspace_name,
sync_on_access,
)?;
match route_mcp(http) {
ServerRoute::McpStdio => serve_mcp(ws.flat, None),
ServerRoute::McpHttp(addr) => serve_mcp(ws.flat, Some(addr)),
ServerRoute::Network => unreachable!("`roteiro mcp` never routes to the network server"),
}
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
#[allow(clippy::needless_return)]
fn run_serve_network(
cfg: &config::Config,
ws: ServeWorkspaces,
workspace_name: Option<&str>,
opts: &ServeOptions,
) -> anyhow::Result<()> {
let ServeWorkspaces { set, flat } = ws;
#[cfg(feature = "serve")]
{
if !served_models(cfg).is_empty() {
return serve_models_endpoint(cfg, set, flat, workspace_name, opts);
}
}
#[cfg(feature = "explorer")]
{
if opts.mcp {
eprintln!(
"note: `--mcp` is ignored here — there is no `/v1` model server to mount `/mcp` \
beside (no model / no `serve` feature); run `roteiro mcp` for the MCP server"
);
}
let default = explorer_default_workspace(&set, workspace_name);
let _ = ♭ return serve_graph_ui(cfg, "serve", set, default, opts.addr.clone());
}
#[cfg(not(feature = "explorer"))]
{
let _ = (cfg, &set, &flat, workspace_name, opts);
#[cfg(feature = "serve")]
anyhow::bail!(
"no installed GGUF models to serve — pull one \
(`roteiro model pull qwen3-0.6b`; see `roteiro model list`), or rebuild with \
`--features explorer` for the llama-free graph API + web UI"
);
#[cfg(not(feature = "serve"))]
anyhow::bail!(
"this build has no network server — for the MCP graph server use `roteiro mcp`; \
rebuild with `--features serve` (model endpoint) or `--features explorer` (graph UI)"
);
}
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
fn build_serve_workspaces(
ingest: rto_graph::IngestConfig,
cfg: &config::Config,
cmd: &str,
workspace_roots: &[String],
workspace_name: Option<&str>,
sync_on_access: bool,
) -> anyhow::Result<ServeWorkspaces> {
use std::sync::Arc;
let resolved = cfg.resolved_workspaces()?;
if resolved.is_empty() && workspace_roots.is_empty() && workspace_name.is_none() {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
let name = repo
.workdir()
.and_then(std::path::Path::file_name)
.map_or_else(|| "repo".to_owned(), |s| s.to_string_lossy().into_owned());
let flat = Arc::new(rto_graph::Workspace::single(name, store));
let set = Arc::new(rto_graph::WorkspaceSet::from_single(
"default",
flat.clone(),
flat.is_multi(),
));
return Ok(ServeWorkspaces { set, flat });
}
let effective = fold_cli_roots(resolved, workspace_roots);
let set = Arc::new(rto_graph::WorkspaceSet::from_resolved(effective.clone())?);
if set.names().is_empty() {
anyhow::bail!(
"no workspaces to serve — run inside a repo, pass `--workspace <ROOT>`, \
or configure `[[workspaces]]` / `[standalone]` in roteiro.toml"
);
}
if let Some(name) = workspace_name {
set.select(Some(name))?;
}
let paths = resolved_repo_paths(&effective, &[])?;
let mut ws = rto_graph::Workspace::from_repo_paths(&paths)?;
if sync_on_access {
ws = ws.with_on_open(Arc::new(move |db: &std::path::Path| {
sync_project_graph(db, ingest).map_err(|e| e.to_string())
}));
}
let flat = Arc::new(ws);
eprintln!(
"roteiro {cmd}: {} workspace(s) [{}] — {} project(s){} — {}",
set.names().len(),
set.names().join(", "),
flat.names().len(),
if sync_on_access {
", sync-on-access"
} else {
""
},
flat.names().join(", ")
);
install_workspace_reload(&flat, cfg.clone(), workspace_roots.to_vec());
Ok(ServeWorkspaces { set, flat })
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
struct ServeWorkspaces {
set: std::sync::Arc<rto_graph::WorkspaceSet>,
flat: std::sync::Arc<rto_graph::Workspace>,
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
fn resolved_repo_paths(
resolved: &[rto_graph::ResolvedWorkspace],
cli_roots: &[String],
) -> anyhow::Result<Vec<std::path::PathBuf>> {
use std::collections::BTreeSet;
let mut seen: BTreeSet<std::path::PathBuf> = BTreeSet::new();
let mut out: Vec<std::path::PathBuf> = Vec::new();
let mut push = |p: std::path::PathBuf, out: &mut Vec<std::path::PathBuf>| {
let key = p.canonicalize().unwrap_or_else(|_| p.clone());
if seen.insert(key) {
out.push(p);
}
};
for root in cli_roots {
for repo in rto_graph::discover_repos_under(std::path::Path::new(root))? {
push(repo, &mut out);
}
}
for rw in resolved {
for root in &rw.roots {
for repo in rto_graph::discover_repos_under(std::path::Path::new(root))? {
push(repo, &mut out);
}
}
for repo in &rw.repos {
push(std::path::PathBuf::from(repo), &mut out);
}
}
Ok(out)
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
fn fold_cli_roots(
mut resolved: Vec<rto_graph::ResolvedWorkspace>,
cli_roots: &[String],
) -> Vec<rto_graph::ResolvedWorkspace> {
if cli_roots.is_empty() {
return resolved;
}
match resolved.iter_mut().find(|r| r.name == "default") {
Some(default) => default.roots.extend(cli_roots.iter().cloned()),
None => resolved.push(rto_graph::ResolvedWorkspace {
name: "default".to_owned(),
roots: cli_roots.to_vec(),
repos: Vec::new(),
linked: true,
}),
}
resolved
}
fn collect_workspace_repo_paths(
ws_cfg: &config::WorkspaceConfig,
cli_roots: &[String],
) -> anyhow::Result<Vec<std::path::PathBuf>> {
let mut repo_paths: Vec<std::path::PathBuf> = Vec::new();
let roots = cli_roots
.iter()
.map(String::as_str)
.chain(ws_cfg.roots.iter().flatten().map(String::as_str));
for root in roots {
repo_paths.extend(rto_graph::discover_repos_under(&config::expand_tilde(
root,
))?);
}
for repo in ws_cfg.repos.iter().flatten() {
repo_paths.push(config::expand_tilde(repo).into_owned());
}
Ok(repo_paths)
}
#[cfg(any(feature = "mcp", feature = "serve", feature = "explorer"))]
fn sync_project_graph(
graph_db: &std::path::Path,
ingest: rto_graph::IngestConfig,
) -> anyhow::Result<()> {
use rto_graph::{ObjectCache, Repo, Store};
let repo_dir = graph_db
.parent()
.and_then(std::path::Path::parent)
.and_then(std::path::Path::parent)
.ok_or_else(|| anyhow::anyhow!("unexpected graph.db path: {}", graph_db.display()))?;
let repo = Repo::discover(repo_dir)?;
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"))?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
Ok(())
}
#[cfg(all(unix, any(feature = "mcp", feature = "serve", feature = "explorer")))]
fn install_workspace_reload(
ws: &std::sync::Arc<rto_graph::Workspace>,
cfg: config::Config,
cli_roots: Vec<String>,
) {
let ws = ws.clone();
std::thread::spawn(move || {
let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_io()
.build()
else {
return;
};
rt.block_on(async move {
let mut hup =
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) {
Ok(sig) => sig,
Err(e) => {
eprintln!("workspace reload disabled (cannot watch SIGHUP): {e}");
return;
}
};
eprintln!("send SIGHUP to reload the workspace (pick up added/removed repos)");
while hup.recv().await.is_some() {
let result = cfg
.resolved_workspaces()
.and_then(|resolved| resolved_repo_paths(&resolved, &cli_roots))
.and_then(|paths| ws.reload_from(paths).map_err(anyhow::Error::from));
match result {
Ok(names) => eprintln!(
"workspace reloaded: {} project(s) — {}",
names.len(),
names.join(", ")
),
Err(e) => eprintln!("workspace reload failed (registry unchanged): {e}"),
}
}
});
});
}
#[cfg(all(
not(unix),
any(feature = "mcp", feature = "serve", feature = "explorer")
))]
fn install_workspace_reload(
_ws: &std::sync::Arc<rto_graph::Workspace>,
_cfg: config::Config,
_cli_roots: Vec<String>,
) {
}
#[cfg(feature = "mcp")]
fn serve_mcp(
workspace: std::sync::Arc<rto_graph::Workspace>,
http: Option<String>,
) -> anyhow::Result<()> {
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(workspace, addr).map_err(|e| anyhow::anyhow!("{e}"))
}
None => rto_render::mcp::serve_stdio(workspace).map_err(|e| anyhow::anyhow!("{e}")),
}
}
#[cfg(all(not(feature = "mcp"), feature = "serve"))]
fn serve_mcp(
_workspace: std::sync::Arc<rto_graph::Workspace>,
_http: Option<String>,
) -> anyhow::Result<()> {
anyhow::bail!(
"MCP serving needs the `mcp` feature (build with `--features mcp`); \
use `--models` for the OpenAI-compatible model endpoint"
)
}
#[cfg(feature = "serve")]
fn served_models(cfg: &config::Config) -> Vec<rto_serve::llama::Served> {
use rto_graph::{ModelKind, Platform, REGISTRY, is_installed, model_dir};
let host = Platform::host();
let wanted = cfg.serve.models.as_deref();
let has_file = |m: &rto_graph::ModelSpec, name: &str| {
m.variant_for(host)
.is_some_and(|v| v.files.iter().any(|f| f.name == name))
};
REGISTRY
.iter()
.filter(|m| wanted.is_none_or(|w| w.iter().any(|n| n == m.name)))
.filter(|m| has_file(m, "model.gguf"))
.filter(|m| match m.kind {
ModelKind::Generative | ModelKind::Embedding => true,
ModelKind::Vision => has_file(m, "mmproj.gguf"),
ModelKind::Ocr | ModelKind::Audio => false,
})
.filter(|m| m.variant_for(host).is_some_and(|v| is_installed(m.name, v)))
.map(|m| rto_serve::llama::Served {
name: m.name.to_owned(),
path: model_dir(m.name).join("model.gguf"),
mmproj: has_file(m, "mmproj.gguf").then(|| model_dir(m.name).join("mmproj.gguf")),
})
.collect()
}
#[cfg(all(feature = "serve", feature = "explorer"))]
fn chat_capable_model_ids(cfg: &config::Config, served_ids: &[String]) -> Vec<String> {
use rto_graph::{ModelKind, find_model};
let is_generative = |id: &str| find_model(id).is_some_and(|s| s.kind == ModelKind::Generative);
let mut ids: Vec<String> = served_ids
.iter()
.filter(|id| !find_model(id).is_some_and(|s| s.kind == ModelKind::Embedding))
.cloned()
.collect();
let default_pos = cfg
.models
.generative
.as_deref()
.and_then(|g| ids.iter().position(|id| id == g && is_generative(id)))
.or_else(|| ids.iter().position(|id| is_generative(id)));
if let Some(pos) = default_pos {
ids[..=pos].rotate_right(1);
}
ids
}
#[cfg(all(test, feature = "serve", feature = "explorer"))]
mod chat_model_selection {
use super::{chat_capable_model_ids, config};
fn ids(v: &[&str]) -> Vec<String> {
v.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn embedding_models_are_excluded_and_a_generative_is_default() {
let served = ids(&["bge-small-en-v1.5-gguf", "qwen3-0.6b", "qwen3-8b"]);
let out = chat_capable_model_ids(&config::Config::default(), &served);
assert_eq!(out, ids(&["qwen3-0.6b", "qwen3-8b"]));
assert!(!out.iter().any(|m| m.contains("bge")), "no embedding model");
}
#[test]
fn configured_generative_is_preferred_as_the_default() {
let mut cfg = config::Config::default();
cfg.models.generative = Some("qwen3-8b".to_owned());
let served = ids(&["bge-small-en-v1.5-gguf", "qwen3-0.6b", "qwen3-8b"]);
let out = chat_capable_model_ids(&cfg, &served);
assert_eq!(out, ids(&["qwen3-8b", "qwen3-0.6b"]));
}
#[test]
fn vision_models_stay_in_the_pool_but_a_generative_leads() {
let served = ids(&["bge-small-en-v1.5-gguf", "smolvlm-500m-gguf", "qwen3-0.6b"]);
let out = chat_capable_model_ids(&config::Config::default(), &served);
assert_eq!(out, ids(&["qwen3-0.6b", "smolvlm-500m-gguf"]));
}
#[test]
fn with_no_generative_a_vision_model_leads_never_an_embedding() {
let served = ids(&["bge-small-en-v1.5-gguf", "smolvlm-500m-gguf"]);
let out = chat_capable_model_ids(&config::Config::default(), &served);
assert_eq!(out, ids(&["smolvlm-500m-gguf"]));
}
#[test]
fn an_embedding_only_serve_offers_no_chat_model() {
let served = ids(&["bge-small-en-v1.5-gguf"]);
assert!(chat_capable_model_ids(&config::Config::default(), &served).is_empty());
}
}
#[cfg(feature = "serve")]
fn serve_models_endpoint(
cfg: &config::Config,
set: std::sync::Arc<rto_graph::WorkspaceSet>,
flat: std::sync::Arc<rto_graph::Workspace>,
workspace_name: Option<&str>,
opts: &ServeOptions,
) -> anyhow::Result<()> {
let (addr, tls_cert, tls_key) = (
opts.addr.clone(),
opts.tls_cert.clone(),
opts.tls_key.clone(),
);
let served = served_models(cfg);
if served.is_empty() {
anyhow::bail!(
"no installed GGUF models to serve — pull one first \
(`roteiro model pull qwen3-0.6b` for chat, \
`roteiro model pull bge-small-en-v1.5-gguf` for embeddings, or \
`roteiro model pull smolvlm-500m-gguf` for vision; \
see `roteiro model list`)"
);
}
let addr = addr
.or_else(|| cfg.serve.addr.clone())
.unwrap_or_else(|| "127.0.0.1:8017".to_owned());
let socket: std::net::SocketAddr = addr
.parse()
.map_err(|e| anyhow::anyhow!("invalid serve address `{addr}`: {e}"))?;
if !socket.ip().is_loopback() {
eprintln!(
"warning: binding a non-loopback address ({socket}) — the endpoint \
has no auth; front it with a reverse proxy (ADR-0006)"
);
}
let names = served
.iter()
.map(|s| s.name.clone())
.collect::<Vec<_>>()
.join(", ");
let budget_bytes = cfg
.serve
.memory_budget_mb
.unwrap_or(0)
.saturating_mul(1024 * 1024);
let engine = rto_serve::llama::LlamaEngine::new_with_budget(served, 0, budget_bytes)
.map_err(|e| anyhow::anyhow!("starting llama.cpp: {e}"))?;
let engine: std::sync::Arc<dyn rto_serve::Engine> = std::sync::Arc::new(engine);
let tls = resolve_serve_tls(
tls_cert.or_else(|| cfg.serve.tls_cert.clone()),
tls_key.or_else(|| cfg.serve.tls_key.clone()),
)?;
serve_v1_tail(
cfg,
ServeSurfaces {
set,
flat,
workspace_name,
},
opts,
engine,
socket,
tls,
&names,
)
}
#[cfg(feature = "serve")]
fn resolve_serve_tls(
cert: Option<String>,
key: Option<String>,
) -> anyhow::Result<Option<(std::path::PathBuf, std::path::PathBuf)>> {
match (cert, key) {
(Some(cert), Some(key)) => Ok(Some((
std::path::PathBuf::from(cert),
std::path::PathBuf::from(key),
))),
(Some(_), None) | (None, Some(_)) => anyhow::bail!(
"TLS needs both a certificate and a key — set both `--tls-cert`/`--tls-key` \
(or `[serve] tls_cert`/`tls_key`), or neither for plain HTTP"
),
(None, None) => Ok(None),
}
}
#[cfg(feature = "serve")]
struct ServeSurfaces<'a> {
set: std::sync::Arc<rto_graph::WorkspaceSet>,
flat: std::sync::Arc<rto_graph::Workspace>,
workspace_name: Option<&'a str>,
}
#[cfg(feature = "serve")]
type SharedToolRegistry = std::sync::Arc<dyn rto_serve::ToolRegistry>;
#[cfg(feature = "serve")]
type WorkspaceToolRegistries = std::collections::HashMap<String, SharedToolRegistry>;
#[cfg(feature = "serve")]
fn serve_v1_tail(
cfg: &config::Config,
surfaces: ServeSurfaces<'_>,
opts: &ServeOptions,
engine: std::sync::Arc<dyn rto_serve::Engine>,
socket: std::net::SocketAddr,
tls: Option<(std::path::PathBuf, std::path::PathBuf)>,
names: &str,
) -> anyhow::Result<()> {
let ServeSurfaces {
set,
flat,
workspace_name,
} = surfaces;
#[cfg(not(feature = "explorer"))]
let _ = (&set, workspace_name);
let scheme = if tls.is_some() { "https" } else { "http" };
let (tools, workspace_tools): (Option<SharedToolRegistry>, WorkspaceToolRegistries) =
if cfg.serve.tools.unwrap_or(true) {
let flat_tools: SharedToolRegistry =
std::sync::Arc::new(GraphToolRegistry::new(flat.clone()));
let per_ws: WorkspaceToolRegistries = set
.workspace_handles()
.into_iter()
.map(|(name, ws)| {
let reg: SharedToolRegistry = std::sync::Arc::new(GraphToolRegistry::new(ws));
(name, reg)
})
.collect();
(Some(flat_tools), per_ws)
} else {
(None, WorkspaceToolRegistries::new())
};
let tools_note = if tools.is_some() {
" (graph tools on)"
} else {
""
};
#[cfg(feature = "explorer")]
let model_ids: Vec<String> = {
let served_ids: Vec<String> = engine.models().into_iter().map(|m| m.id).collect();
chat_capable_model_ids(cfg, &served_ids)
};
let router = match tools {
Some(tools) => rto_serve::app_with_workspace_tools(engine, tools, workspace_tools),
None => rto_serve::app(engine),
};
#[cfg(feature = "explorer")]
let router = {
let default = explorer_default_workspace(&set, workspace_name);
mount_explorer_surfaces(router, set, default, model_ids)
};
#[cfg(feature = "explorer")]
let graph_note = " + /v1/graph + / (UI, Ask on)";
#[cfg(not(feature = "explorer"))]
let graph_note = "";
if opts.mcp {
#[cfg(feature = "mcp")]
{
let combined = router.merge(rto_render::mcp::mcp_router(flat));
eprintln!(
"roteiro server listening on {scheme}://{socket} — /v1{tools_note}{graph_note} + /mcp — serving: {names}"
);
return match tls {
Some((cert, key)) => {
rto_serve::serve_blocking_router_tls(combined, socket, &cert, &key)
}
None => rto_serve::serve_blocking_router(combined, socket),
};
}
#[cfg(not(feature = "mcp"))]
anyhow::bail!(
"`roteiro serve --mcp` needs the `mcp` feature (build with `--features serve,mcp`)"
);
}
eprintln!(
"roteiro model server listening on {scheme}://{socket}/v1{tools_note}{graph_note} — serving: {names}"
);
match tls {
Some((cert, key)) => rto_serve::serve_blocking_router_tls(router, socket, &cert, &key),
None => rto_serve::serve_blocking_router(router, socket),
}
}
#[cfg(all(feature = "serve", feature = "explorer"))]
fn mount_explorer_surfaces(
router: axum::Router,
set: std::sync::Arc<rto_graph::WorkspaceSet>,
default: Option<String>,
model_ids: Vec<String>,
) -> axum::Router {
let caps = crate::graph_api::Capabilities {
ask: true,
models: model_ids,
};
router
.merge(crate::graph_api::router_with_capabilities(
set, default, caps,
))
.merge(crate::explorer_app::router())
}
#[cfg(feature = "serve")]
fn qualified_or(key: &str, project: Option<&str>) -> (Option<String>, String) {
match rto_graph::parse_qualified(key) {
Some((p, bare)) => (Some(p.to_owned()), bare.to_owned()),
None => (project.map(str::to_owned), key.to_owned()),
}
}
#[cfg(feature = "serve")]
struct GraphToolRegistry {
workspace: std::sync::Arc<rto_graph::Workspace>,
}
#[cfg(feature = "serve")]
impl GraphToolRegistry {
fn new(workspace: std::sync::Arc<rto_graph::Workspace>) -> Self {
Self { workspace }
}
fn run<T: serde::Serialize>(
&self,
project: Option<&str>,
query: impl FnOnce(&rto_graph::Store) -> Result<T, rto_graph::StoreError>,
) -> Result<String, String> {
let result = self
.workspace
.with_store(project, query)
.map_err(|e| e.to_string())?;
let value = result.map_err(|e| e.to_string())?;
serde_json::to_string(&value).map_err(|e| e.to_string())
}
}
#[cfg(feature = "serve")]
impl rto_serve::ToolRegistry for GraphToolRegistry {
fn tools(&self) -> Vec<rto_serve::ToolDef> {
use serde_json::json;
let with_project = |mut props: serde_json::Value| {
let obj = props.as_object_mut().expect("object schema");
obj.insert(
"project".to_owned(),
json!({
"type": "string",
"description": "Optional: which hosted project to query (see \
`list_projects`); omit if the server hosts one.",
}),
);
props
};
let mut tools = vec![
rto_serve::ToolDef {
name: "explain".to_owned(),
description: "Explain a graph node by key (its record and immediate \
neighbours), e.g. `fn:foo` or `file:src/main.rs`. A key may be \
project-qualified (`<project>::<key>`) to follow a cross-repo \
link into another hosted project (see `list_projects`)."
.to_owned(),
parameters: json!({
"type": "object",
"properties": with_project(json!({ "key": { "type": "string" } })),
"required": ["key"],
}),
},
rto_serve::ToolDef {
name: "search".to_owned(),
description: "Search graph nodes by text — names, keys, paths, and captured \
content (doc comments, README/ADR/blueprint prose). Returns the \
top matches with keys and, for content-bearing nodes, a short \
`snippet` of the node's actual content to ground your answer; \
curated ADRs/blueprints and READMEs rank first, so this is the \
entry point for \"what is X / why\" questions. Read the `snippet`, \
and call `explain` on a returned key for the full content."
.to_owned(),
parameters: json!({
"type": "object",
"properties": with_project(json!({
"query": { "type": "string" },
"limit": { "type": "integer", "minimum": 1, "maximum": 25 },
})),
"required": ["query"],
}),
},
rto_serve::ToolDef {
name: "path".to_owned(),
description: "Find a shortest path between two node keys. A path lives \
within one project; a project-qualified `from` \
(`<project>::<key>`) selects it (see `list_projects`)."
.to_owned(),
parameters: json!({
"type": "object",
"properties": with_project(json!({
"from": { "type": "string" },
"to": { "type": "string" },
})),
"required": ["from", "to"],
}),
},
rto_serve::ToolDef {
name: "debt".to_owned(),
description: "List intent-debt markers (todo/fixme/hack/stub/deferred), \
optionally filtered by category."
.to_owned(),
parameters: json!({
"type": "object",
"properties": with_project(json!({
"categories": { "type": "array", "items": { "type": "string" } },
})),
}),
},
];
tools.push(rto_serve::ToolDef {
name: "list_projects".to_owned(),
description: "List the projects this server hosts (often just one). Pass one as \
`project` to the other tools to query it (ADR-0008)."
.to_owned(),
parameters: json!({ "type": "object", "properties": {} }),
});
tools
}
fn projects(&self) -> Vec<String> {
self.workspace.names()
}
fn call(&self, name: &str, args: &serde_json::Value) -> Result<String, String> {
let str_arg = |k: &str| args.get(k).and_then(serde_json::Value::as_str);
let project = str_arg("project");
match name {
"list_projects" => serde_json::to_string(&serde_json::json!({
"projects": self.workspace.names(),
}))
.map_err(|e| e.to_string()),
"explain" => {
let key = str_arg("key").ok_or("`explain` needs a string `key`")?;
let (proj, bare) = qualified_or(key, project);
self.run(proj.as_deref(), move |store| {
rto_graph::explain(store, &bare)
})
}
"search" => {
let query = str_arg("query")
.ok_or("`search` needs a string `query`")?
.to_owned();
let limit = args
.get("limit")
.and_then(serde_json::Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
.unwrap_or(10)
.clamp(1, 25);
self.run(project, |store| rto_graph::search(store, &query, limit))
}
"path" => {
let from = str_arg("from").ok_or("`path` needs a string `from`")?;
let to = str_arg("to").ok_or("`path` needs a string `to`")?;
let (proj, from_bare) = qualified_or(from, project);
let to_bare = rto_graph::parse_qualified(to)
.map_or_else(|| to.to_owned(), |(_, b)| b.to_owned());
self.run(proj.as_deref(), move |store| {
rto_graph::path(store, &from_bare, &to_bare)
})
}
"debt" => {
let categories: Vec<String> = args
.get("categories")
.and_then(serde_json::Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default();
self.run(project, |store| rto_graph::debt(store, &categories, &[]))
}
other => Err(format!("unknown tool `{other}`")),
}
}
}
fn run_render(
ingest: rto_graph::IngestConfig,
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(ingest, 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,
});
}
for dir in ["docs/blueprint", "docs/blueprints"] {
let bp_dir = root.join(dir);
if !bp_dir.is_dir() {
continue;
}
let mut bps: Vec<_> = std::fs::read_dir(&bp_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();
bps.sort();
for path in &bps {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("blueprint");
let md = std::fs::read_to_string(path)?;
let rendered = rto_render::render_doc(&md, stem);
std::fs::write(out.join(format!("{stem}.html")), &rendered.html)?;
lifetime.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(&lifetime, &entries),
)?;
println!(
"rendered docs → {} ({} ADR page(s), {} lifetime doc(s))",
out.display(),
entries.len(),
lifetime.len(),
);
Ok(())
}
fn render_obsidian(ingest: rto_graph::IngestConfig, out: Option<String>) -> anyhow::Result<()> {
let (repo, mut store, cache) = open_graph()?;
build_graph(&repo, &mut store, &cache, ingest, GraphSource::Committed)?;
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 commit = repo.head_commit_id().ok();
let remote = repo.origin_url();
let source_base = match (remote.as_deref(), commit.as_deref()) {
(Some(r), Some(c)) => source_blob_base(r, c),
_ => None,
};
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, source_base.as_deref());
std::fs::write(out.join(¬e.filename), ¬e.content)?;
count += 1;
}
}
let repo_url = remote.as_deref().and_then(repo_web_root);
let home = rto_render::render_home(&vault_summary(&repo, &store, repo_url, commit)?);
std::fs::write(out.join(&home.filename), &home.content)?;
println!(
"rendered obsidian vault → {} ({count} note(s) + {})",
out.display(),
rto_render::HOME_NOTE
);
Ok(())
}
fn vault_summary(
repo: &rto_graph::Repo,
store: &rto_graph::Store,
repo_url: Option<String>,
commit: Option<String>,
) -> anyhow::Result<rto_render::VaultSummary> {
use rto_graph::{NodeKind, Provenance};
let project = repo
.workdir()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("this project")
.to_owned();
let mut by_kind: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for node in store.all_nodes()? {
*by_kind.entry(node.kind.as_str().to_owned()).or_default() += 1;
}
let mut node_counts: Vec<(String, usize)> = by_kind.into_iter().collect();
node_counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let mut edge_provenance = Vec::new();
for p in [
Provenance::Derived,
Provenance::Authored,
Provenance::Inferred,
] {
let n = store.edges_by_provenance(p)?.len();
if n > 0 {
edge_provenance.push((p.as_str().to_owned(), n));
}
}
let adrs = store
.nodes_by_kind(&NodeKind::Adr)?
.into_iter()
.map(|n| rto_render::AdrEntry {
key: n.key,
name: n.name,
status: n
.meta
.get("status")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned),
})
.collect();
let debt = rto_graph::debt(store, &[], &[])?
.by_category
.into_iter()
.collect();
Ok(rto_render::VaultSummary {
project,
total_nodes: usize::try_from(store.node_count()?)?,
total_edges: usize::try_from(store.edge_count()?)?,
node_counts,
edge_provenance,
adrs,
debt,
repo_url,
commit,
})
}
fn repo_web_root(remote: &str) -> Option<String> {
let s = remote.trim();
let hostpath = if let Some(rest) = s.strip_prefix("git@") {
rest.replacen(':', "/", 1)
} else {
let rest = s
.strip_prefix("ssh://")
.or_else(|| s.strip_prefix("https://"))
.or_else(|| s.strip_prefix("http://"))?;
rest.rsplit_once('@').map_or(rest, |(_, r)| r).to_owned()
};
let hostpath = hostpath
.strip_suffix(".git")
.unwrap_or(&hostpath)
.trim_end_matches('/');
if hostpath.split('/').filter(|s| !s.is_empty()).count() < 2 {
return None;
}
Some(format!("https://{hostpath}"))
}
fn source_blob_base(remote: &str, commit: &str) -> Option<String> {
let root = repo_web_root(remote)?;
let infix = if root.contains("gitlab") {
"/-/blob/"
} else {
"/blob/"
};
Some(format!("{root}{infix}{commit}"))
}
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(())
}
#[cfg(test)]
mod url_tests {
use super::{repo_web_root, source_blob_base};
#[test]
fn repo_web_root_maps_common_remote_forms() {
let want = Some("https://github.com/OffeneDatenmodellierung/Roteiro".to_owned());
for remote in [
"git@github.com:OffeneDatenmodellierung/Roteiro.git",
"https://github.com/OffeneDatenmodellierung/Roteiro.git",
"https://github.com/OffeneDatenmodellierung/Roteiro",
"ssh://git@github.com/OffeneDatenmodellierung/Roteiro.git",
"https://user:tok@github.com/OffeneDatenmodellierung/Roteiro.git",
] {
assert_eq!(repo_web_root(remote), want, "{remote}");
}
assert_eq!(repo_web_root("file:///tmp/x.git"), None);
assert_eq!(repo_web_root("git@github.com:"), None, "no owner/repo");
}
#[test]
fn source_blob_base_uses_host_specific_infix() {
assert_eq!(
source_blob_base("git@github.com:o/r.git", "abc123"),
Some("https://github.com/o/r/blob/abc123".to_owned())
);
assert_eq!(
source_blob_base("git@gitlab.com:o/r.git", "abc123"),
Some("https://gitlab.com/o/r/-/blob/abc123".to_owned())
);
}
}
#[cfg(all(test, feature = "execution"))]
mod security_cli {
use super::{
Cli, Command, SecurityAction, SecurityIngestReport, SecurityListing, report_analyzer,
};
use clap::Parser as _;
fn parse<const N: usize>(args: [&str; N]) -> Command {
Cli::try_parse_from(args).expect("parse").command
}
fn action<const N: usize>(args: [&str; N]) -> SecurityAction {
let Command::Security { action } = parse(args) else {
panic!("expected Security");
};
action
}
#[test]
fn ingest_takes_one_report_argument() {
let SecurityAction::Ingest { file, json } =
action(["roteiro", "security", "ingest", "r.json"])
else {
panic!("expected Ingest");
};
assert_eq!(file, "r.json");
assert!(!json);
}
#[test]
fn ingest_reads_stdin_and_emits_json_like_its_neighbours() {
let SecurityAction::Ingest { file, json } =
action(["roteiro", "security", "ingest", "-", "--json"])
else {
panic!("expected Ingest");
};
assert_eq!(file, "-", "`-` is stdin, matching `roteiro load`");
assert!(json);
}
#[test]
fn list_defaults_to_every_analyzer_and_can_narrow() {
let SecurityAction::List { analyzer, json } = action(["roteiro", "security", "list"])
else {
panic!("expected List");
};
assert_eq!(analyzer, None);
assert!(!json);
let SecurityAction::List { analyzer, json } = action([
"roteiro",
"security",
"list",
"--analyzer",
"cargo-audit",
"--json",
]) else {
panic!("expected List");
};
assert_eq!(analyzer.as_deref(), Some("cargo-audit"));
assert!(json);
}
#[test]
fn security_requires_an_action() {
assert!(Cli::try_parse_from(["roteiro", "security"]).is_err());
}
#[test]
fn the_analyzer_is_read_out_of_the_report() {
let report = br#"{"schema":"roteiro.findings/v1","analyzer":"cargo-audit"}"#;
assert_eq!(
report_analyzer(report, "r.json").expect("peek"),
"cargo-audit"
);
}
#[test]
fn a_file_that_is_not_a_report_fails_with_a_message_naming_it() {
let err = report_analyzer(b"not json", "r.json").expect_err("must fail");
let message = err.to_string();
assert!(
message.contains("r.json") && message.contains("not a normalized analyzer report"),
"unhelpful error: {message}"
);
assert!(report_analyzer(b"{\"nope\":1}", "r.json").is_err());
}
#[test]
fn the_json_shapes_are_the_documented_ones() {
let report = SecurityIngestReport {
layer: "security:cargo-audit:ab12cd34".to_owned(),
analyzer: "cargo-audit".to_owned(),
analyzer_version: "0.21.0".to_owned(),
runner: rto_graph::RunnerKind::Ingested,
isolation: rto_graph::Isolation::Ingested,
findings: 2,
removed: 3,
replaced: true,
report_digest: "abc".to_owned(),
};
let value = serde_json::to_value(&report).expect("serialize");
assert_eq!(value["runner"], "ingested");
assert_eq!(value["isolation"], "ingested");
assert_eq!(value["removed"], 3);
assert_eq!(value["replaced"], true);
let listing = SecurityListing {
layers: Vec::new(),
findings: 0,
};
let value = serde_json::to_value(&listing).expect("serialize");
assert_eq!(value["findings"], 0);
assert!(value["layers"].as_array().expect("array").is_empty());
}
}
#[cfg(all(test, any(feature = "serve", feature = "mcp", feature = "explorer")))]
mod cli_routing {
#[cfg(any(feature = "mcp", feature = "serve"))]
use super::route_mcp;
use super::{Cli, Command, ServerRoute, route_serve, serve_deprecation_notice};
use clap::Parser as _;
fn parse<const N: usize>(args: [&str; N]) -> Command {
Cli::try_parse_from(args).expect("parse").command
}
#[test]
fn bare_serve_routes_to_the_network_server() {
let Command::Serve {
http, models, mcp, ..
} = parse(["roteiro", "serve"])
else {
panic!("expected Serve");
};
assert_eq!(http, None);
assert!(!models);
assert!(!mcp);
assert_eq!(route_serve(http), ServerRoute::Network);
assert_eq!(serve_deprecation_notice(models, None), None);
}
#[cfg(any(feature = "mcp", feature = "serve"))]
#[test]
fn mcp_routes_to_stdio_by_default() {
let Command::Mcp { http, .. } = parse(["roteiro", "mcp"]) else {
panic!("expected Mcp");
};
assert_eq!(http, None);
assert_eq!(route_mcp(http), ServerRoute::McpStdio);
}
#[cfg(any(feature = "mcp", feature = "serve"))]
#[test]
fn mcp_http_routes_to_networked_mcp() {
let Command::Mcp { http, .. } = parse(["roteiro", "mcp", "--http", "127.0.0.1:8080"])
else {
panic!("expected Mcp");
};
assert_eq!(
route_mcp(http),
ServerRoute::McpHttp("127.0.0.1:8080".to_owned())
);
}
#[cfg(any(feature = "mcp", feature = "serve"))]
#[test]
fn mcp_carries_the_workspace_options() {
let Command::Mcp {
workspace,
workspace_name,
sync_on_access,
..
} = parse([
"roteiro",
"mcp",
"-w",
"api",
"--workspace",
"/repos",
"--sync-on-access",
])
else {
panic!("expected Mcp");
};
assert_eq!(workspace, vec!["/repos".to_owned()]);
assert_eq!(workspace_name.as_deref(), Some("api"));
assert!(sync_on_access);
}
#[test]
fn deprecated_serve_models_is_the_default_with_a_notice() {
let Command::Serve { http, models, .. } = parse(["roteiro", "serve", "--models"]) else {
panic!("expected Serve");
};
assert!(models);
assert_eq!(route_serve(http.clone()), ServerRoute::Network);
let notice = serve_deprecation_notice(models, http.as_deref()).expect("notice");
assert!(
notice.contains("--models") && notice.contains("default"),
"unexpected notice: {notice}"
);
}
#[test]
fn deprecated_serve_http_routes_to_mcp_with_a_notice() {
let Command::Serve { http, models, .. } =
parse(["roteiro", "serve", "--http", "127.0.0.1:9"])
else {
panic!("expected Serve");
};
assert_eq!(
route_serve(http.clone()),
ServerRoute::McpHttp("127.0.0.1:9".to_owned())
);
let notice = serve_deprecation_notice(models, http.as_deref()).expect("notice");
assert!(
notice.contains("roteiro mcp --http"),
"unexpected notice: {notice}"
);
}
#[test]
fn serve_http_conflicts_with_models() {
assert!(
Cli::try_parse_from(["roteiro", "serve", "--http", "127.0.0.1:9", "--models"]).is_err()
);
}
}
#[cfg(all(test, feature = "serve"))]
mod workspace_scoped_tools {
use super::GraphToolRegistry;
fn two_workspace_set() -> (
std::sync::Arc<rto_graph::WorkspaceSet>,
std::sync::Arc<rto_graph::Workspace>,
) {
let ws_api =
rto_graph::Workspace::single("api", rto_graph::Store::open_in_memory().expect("store"));
let ws_docs = rto_graph::Workspace::single(
"docs",
rto_graph::Store::open_in_memory().expect("store"),
);
let set = std::sync::Arc::new(rto_graph::WorkspaceSet::from_workspaces([
("api".to_owned(), ws_api, true),
("docs".to_owned(), ws_docs, false),
]));
let flat = std::sync::Arc::new(rto_graph::Workspace::from_stores([
("api", rto_graph::Store::open_in_memory().expect("store")),
("docs", rto_graph::Store::open_in_memory().expect("store")),
]));
(set, flat)
}
fn registry_for(set: &rto_graph::WorkspaceSet, ws: &str) -> GraphToolRegistry {
let handle = set
.workspace_handles()
.into_iter()
.find(|(name, _)| name == ws)
.map(|(_, h)| h)
.expect("workspace present");
GraphToolRegistry::new(handle)
}
#[test]
fn list_projects_returns_only_the_selected_workspaces_projects() {
use rto_serve::ToolRegistry as _;
let (set, flat) = two_workspace_set();
let api = registry_for(&set, "api");
let out = api.call("list_projects", &serde_json::json!({})).unwrap();
let json: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(
json["projects"],
serde_json::json!(["api"]),
"the `api` workspace Ask must list only `api`"
);
assert_eq!(api.projects(), vec!["api".to_owned()]);
let docs = registry_for(&set, "docs");
let out = docs.call("list_projects", &serde_json::json!({})).unwrap();
let json: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(json["projects"], serde_json::json!(["docs"]));
let flat = GraphToolRegistry::new(flat);
let out = flat.call("list_projects", &serde_json::json!({})).unwrap();
let json: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(json["projects"], serde_json::json!(["api", "docs"]));
}
#[test]
fn a_tool_call_for_an_out_of_workspace_project_is_refused() {
use rto_serve::ToolRegistry as _;
let (set, _flat) = two_workspace_set();
let api = registry_for(&set, "api");
let err = api
.call(
"explain",
&serde_json::json!({ "key": "fn:x", "project": "docs" }),
)
.expect_err("out-of-workspace project must be refused");
assert!(
err.contains("no project named `docs`"),
"the refusal names the unknown project (was: {err})"
);
let err = api
.call("explain", &serde_json::json!({ "key": "docs::fn:x" }))
.expect_err("qualified out-of-workspace key must be refused");
assert!(err.contains("no project named `docs`"), "was: {err}");
let ok = api.call(
"explain",
&serde_json::json!({ "key": "fn:x", "project": "api" }),
);
assert!(
ok.is_ok(),
"the workspace's own project must still resolve: {ok:?}"
);
}
}
#[cfg(all(test, feature = "serve", feature = "explorer"))]
mod serve_explorer_wiring {
use super::{GraphToolRegistry, mount_explorer_surfaces};
use axum::body::Body;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt as _;
use tower::ServiceExt as _;
struct MockEngine;
impl rto_serve::Engine for MockEngine {
fn models(&self) -> Vec<rto_serve::ModelInfo> {
vec![rto_serve::ModelInfo {
id: "qwen3-0.6b".to_owned(),
}]
}
fn chat_stream(
&self,
_req: &rto_serve::ChatRequest,
on_token: &mut dyn FnMut(&str),
) -> Result<rto_serve::CompletionStats, rto_serve::EngineError> {
on_token("a grounded answer");
Ok(rto_serve::CompletionStats {
prompt_tokens: 1,
completion_tokens: 3,
finish_reason: rto_serve::FinishReason::Stop,
})
}
}
fn serve_router_for(
set: std::sync::Arc<rto_graph::WorkspaceSet>,
flat: std::sync::Arc<rto_graph::Workspace>,
default: Option<String>,
) -> axum::Router {
let engine: std::sync::Arc<dyn rto_serve::Engine> = std::sync::Arc::new(MockEngine);
let tools: std::sync::Arc<dyn rto_serve::ToolRegistry> =
std::sync::Arc::new(GraphToolRegistry::new(flat));
let workspace_tools: std::collections::HashMap<
String,
std::sync::Arc<dyn rto_serve::ToolRegistry>,
> = set
.workspace_handles()
.into_iter()
.map(|(name, ws)| {
let reg: std::sync::Arc<dyn rto_serve::ToolRegistry> =
std::sync::Arc::new(GraphToolRegistry::new(ws));
(name, reg)
})
.collect();
let model_ids = engine.models().into_iter().map(|m| m.id).collect();
let base = rto_serve::app_with_workspace_tools(engine, tools, workspace_tools);
mount_explorer_surfaces(base, set, default, model_ids)
}
fn serve_router() -> axum::Router {
let store = rto_graph::Store::open_in_memory().expect("in-memory store");
let flat = std::sync::Arc::new(rto_graph::Workspace::single("repo", store));
let set = std::sync::Arc::new(rto_graph::WorkspaceSet::from_single(
"default",
flat.clone(),
flat.is_multi(),
));
serve_router_for(set, flat, Some("default".to_owned()))
}
fn multi_serve_router() -> axum::Router {
let ws_api =
rto_graph::Workspace::single("api", rto_graph::Store::open_in_memory().expect("store"));
let ws_docs = rto_graph::Workspace::single(
"docs",
rto_graph::Store::open_in_memory().expect("store"),
);
let set = std::sync::Arc::new(rto_graph::WorkspaceSet::from_workspaces([
("api".to_owned(), ws_api, true),
("docs".to_owned(), ws_docs, false),
]));
let flat = std::sync::Arc::new(rto_graph::Workspace::from_stores([
("api", rto_graph::Store::open_in_memory().expect("store")),
("docs", rto_graph::Store::open_in_memory().expect("store")),
]));
serve_router_for(set, flat, None)
}
async fn get(uri: &str) -> (StatusCode, String, String) {
get_on(serve_router(), uri).await
}
async fn get_on(router: axum::Router, uri: &str) -> (StatusCode, String, String) {
let resp = router
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let ct = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
let body = resp.into_body().collect().await.unwrap().to_bytes();
(status, ct, String::from_utf8_lossy(&body).into_owned())
}
#[tokio::test]
async fn capabilities_report_ask_on_and_the_served_model() {
let (status, ct, body) = get("/v1/graph/capabilities").await;
assert_eq!(status, StatusCode::OK);
assert!(ct.contains("application/json"), "content-type was {ct}");
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(json["ask"], true, "the serve build enables Ask");
assert_eq!(
json["models"],
serde_json::json!(["qwen3-0.6b"]),
"capabilities name the served model"
);
}
struct EmbeddingAndGenerativeEngine;
impl rto_serve::Engine for EmbeddingAndGenerativeEngine {
fn models(&self) -> Vec<rto_serve::ModelInfo> {
["bge-small-en-v1.5-gguf", "qwen3-0.6b"]
.into_iter()
.map(|id| rto_serve::ModelInfo { id: id.to_owned() })
.collect()
}
fn chat_stream(
&self,
_req: &rto_serve::ChatRequest,
on_token: &mut dyn FnMut(&str),
) -> Result<rto_serve::CompletionStats, rto_serve::EngineError> {
on_token("ok");
Ok(rto_serve::CompletionStats {
prompt_tokens: 1,
completion_tokens: 1,
finish_reason: rto_serve::FinishReason::Stop,
})
}
}
#[tokio::test]
async fn capabilities_omit_the_embedding_model_from_ask() {
let store = rto_graph::Store::open_in_memory().expect("in-memory store");
let flat = std::sync::Arc::new(rto_graph::Workspace::single("repo", store));
let set = std::sync::Arc::new(rto_graph::WorkspaceSet::from_single(
"default",
flat.clone(),
flat.is_multi(),
));
let engine: std::sync::Arc<dyn rto_serve::Engine> =
std::sync::Arc::new(EmbeddingAndGenerativeEngine);
let tools: std::sync::Arc<dyn rto_serve::ToolRegistry> =
std::sync::Arc::new(GraphToolRegistry::new(flat));
let served_ids: Vec<String> = engine.models().into_iter().map(|m| m.id).collect();
let model_ids =
super::chat_capable_model_ids(&super::config::Config::default(), &served_ids);
let base = rto_serve::app_with_tools(engine, tools);
let router = mount_explorer_surfaces(base, set, Some("default".to_owned()), model_ids);
let (status, _ct, body) = get_on(router, "/v1/graph/capabilities").await;
assert_eq!(status, StatusCode::OK);
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
json["models"],
serde_json::json!(["qwen3-0.6b"]),
"the embedding model must be excluded from the Ask pool"
);
}
#[tokio::test]
async fn the_explorer_ui_is_served_beside_the_model_endpoint() {
let (status, ct, body) = get("/").await;
assert_eq!(status, StatusCode::OK);
assert!(ct.starts_with("text/html"), "content-type was {ct}");
assert!(body.contains("<!doctype html>"));
assert!(body.contains("/app.js"), "the shell loads our app");
}
#[tokio::test]
async fn the_graph_grounded_chat_route_is_mounted() {
let body = serde_json::json!({
"model": "qwen3-0.6b",
"messages": [{ "role": "user", "content": "what is this repo?" }],
"stream": false,
});
let resp = serve_router()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/repo/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the project-scoped chat route must be mounted and reachable"
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json["choices"][0]["message"]["content"], "a grounded answer",
"the mounted route returns the engine's completion"
);
}
#[tokio::test]
async fn serve_hosts_all_configured_workspaces_with_no_cwd_repo() {
let (status, ct, body) = get_on(multi_serve_router(), "/v1/graph/workspaces").await;
assert_eq!(status, StatusCode::OK);
assert!(ct.contains("application/json"), "content-type was {ct}");
let arr: serde_json::Value = serde_json::from_str(&body).unwrap();
let arr = arr.as_array().expect("workspaces array");
assert_eq!(arr.len(), 2, "both configured workspaces are hosted");
assert_eq!(arr[0]["name"], "api");
assert_eq!(arr[0]["linked"], true);
assert_eq!(arr[1]["name"], "docs");
assert_eq!(arr[1]["linked"], false, "a standalone repo is unlinked");
}
#[tokio::test]
async fn nested_graph_routes_reach_each_configured_workspace() {
for (ws, project) in [("api", "api"), ("docs", "docs")] {
let (status, _, body) = get_on(
multi_serve_router(),
&format!("/v1/graph/workspaces/{ws}/projects"),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"workspace `{ws}` must be reachable via its nested route"
);
assert!(
body.contains(project),
"workspace `{ws}` hosts project `{project}` (was: {body})"
);
}
}
#[tokio::test]
async fn the_model_tools_span_every_hosted_project() {
for project in ["api", "docs"] {
let body = serde_json::json!({
"model": "qwen3-0.6b",
"messages": [{ "role": "user", "content": "what is this project?" }],
"stream": false,
});
let resp = multi_serve_router()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/v1/{project}/chat/completions"))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the project-scoped chat route must resolve `{project}` across the set"
);
}
}
#[tokio::test]
async fn the_unscoped_chat_route_is_preserved() {
let body = serde_json::json!({
"model": "qwen3-0.6b",
"messages": [{ "role": "user", "content": "tell me about the docs repo" }],
"stream": false,
});
let resp = multi_serve_router()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the unscoped chat route must stay mounted"
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json["choices"][0]["message"]["content"], "a grounded answer",
"the unscoped route returns the engine's completion"
);
}
#[tokio::test]
async fn the_workspace_scoped_chat_route_reaches_each_configured_workspace() {
for ws in ["api", "docs"] {
let body = serde_json::json!({
"model": "qwen3-0.6b",
"messages": [{ "role": "user", "content": "what does this workspace do?" }],
"stream": false,
});
let resp = multi_serve_router()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/v1/workspaces/{ws}/chat/completions"))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the workspace-scoped chat route must resolve `{ws}`"
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
json["choices"][0]["message"]["content"], "a grounded answer",
"the scoped route returns the engine's completion"
);
}
}
#[tokio::test]
async fn an_unknown_workspace_scoped_chat_route_is_a_404() {
let body = serde_json::json!({
"model": "qwen3-0.6b",
"messages": [{ "role": "user", "content": "anything" }],
"stream": false,
});
let resp = multi_serve_router()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/workspaces/does-not-exist/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"an unknown workspace must 404, not resolve against another workspace"
);
}
#[tokio::test]
async fn the_single_workspace_scoped_chat_route_matches_the_default() {
let resp = serve_router()
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/workspaces/default/chat/completions")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"model": "qwen3-0.6b",
"messages": [{ "role": "user", "content": "what is this repo?" }],
"stream": false,
}))
.unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"the single workspace must be addressable by its `default` name"
);
}
#[test]
fn unknown_workspace_name_fails_fast_listing_the_known_ones() {
let ws_api =
rto_graph::Workspace::single("api", rto_graph::Store::open_in_memory().unwrap());
let ws_docs =
rto_graph::Workspace::single("docs", rto_graph::Store::open_in_memory().unwrap());
let set = rto_graph::WorkspaceSet::from_workspaces([
("api".to_owned(), ws_api, true),
("docs".to_owned(), ws_docs, false),
]);
let err = set.select(Some("nope")).err().expect("unknown must error");
let msg = err.to_string();
assert!(msg.contains("no workspace named `nope`"), "was: {msg}");
assert!(msg.contains("api") && msg.contains("docs"), "was: {msg}");
assert!(set.select(Some("api")).is_ok());
}
#[test]
fn legacy_single_repo_folds_to_one_default_workspace() {
let flat = std::sync::Arc::new(rto_graph::Workspace::single(
"repo",
rto_graph::Store::open_in_memory().unwrap(),
));
let set = rto_graph::WorkspaceSet::from_single("default", flat.clone(), flat.is_multi());
assert_eq!(set.names(), vec!["default".to_owned()]);
assert_eq!(set.select(None).unwrap().names(), vec!["repo".to_owned()]);
}
}
#[cfg(all(test, any(feature = "serve", feature = "mcp")))]
mod serve_workspace_paths_tests {
use super::{fold_cli_roots, resolved_repo_paths};
use rto_graph::ResolvedWorkspace;
#[test]
fn cli_roots_fold_into_a_default_workspace() {
let folded = fold_cli_roots(Vec::new(), &["/a".to_owned(), "/b".to_owned()]);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].name, "default");
assert!(folded[0].linked);
assert_eq!(folded[0].roots, vec!["/a".to_owned(), "/b".to_owned()]);
let existing = vec![ResolvedWorkspace {
name: "default".to_owned(),
roots: vec!["/cfg".to_owned()],
repos: vec!["/cfg/extra".to_owned()],
linked: true,
}];
let folded = fold_cli_roots(existing, &["/cli".to_owned()]);
assert_eq!(folded.len(), 1, "no duplicate `default` group");
assert_eq!(folded[0].roots, vec!["/cfg".to_owned(), "/cli".to_owned()]);
assert_eq!(
folded[0].repos,
vec!["/cfg/extra".to_owned()],
"repos untouched"
);
let named = vec![ResolvedWorkspace {
name: "api".to_owned(),
roots: vec!["/api".to_owned()],
repos: Vec::new(),
linked: true,
}];
let folded = fold_cli_roots(named.clone(), &[]);
assert_eq!(folded, named);
}
#[test]
fn unions_every_group_and_cli_root_deduped_by_path() {
let base = std::env::temp_dir().join(format!("rto-srv-paths-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
for sub in ["scan/alpha/.git", "scan/beta/.git", "solo/gamma/.git"] {
std::fs::create_dir_all(base.join(sub)).expect("mkrepo");
}
let scan = base.join("scan").to_string_lossy().into_owned();
let alpha = base.join("scan/alpha").to_string_lossy().into_owned();
let gamma_root = base.join("solo").to_string_lossy().into_owned();
let resolved = vec![
ResolvedWorkspace {
name: "linked".to_owned(),
roots: vec![scan.clone()],
repos: vec![alpha.clone()],
linked: true,
},
ResolvedWorkspace {
name: "gamma".to_owned(),
roots: Vec::new(),
repos: vec![base.join("solo/gamma").to_string_lossy().into_owned()],
linked: false,
},
];
let paths = resolved_repo_paths(&resolved, &[gamma_root]).expect("union");
let mut got: Vec<_> = paths
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
got.sort();
assert_eq!(
got,
vec!["alpha".to_owned(), "beta".to_owned(), "gamma".to_owned()],
"each repo is hosted exactly once across all groups + cli roots"
);
std::fs::remove_dir_all(&base).ok();
}
}
#[cfg(test)]
mod workspace_tests {
use rto_graph::discover_repos_under;
#[test]
fn discovers_the_root_and_immediate_repo_subdirs_only() {
let base = std::env::temp_dir().join(format!("rto-disc-{}", std::process::id()));
std::fs::remove_dir_all(&base).ok();
for sub in ["alpha/.git", "beta/.git", "notarepo", "beta/deep/.git"] {
std::fs::create_dir_all(base.join(sub)).expect("mkdir");
}
let found = discover_repos_under(&base).expect("scan");
assert_eq!(found, vec![base.join("alpha"), base.join("beta")]);
std::fs::create_dir_all(base.join(".git")).expect("mkdir root .git");
let found = discover_repos_under(&base).expect("scan");
assert_eq!(
found,
vec![base.clone(), base.join("alpha"), base.join("beta")]
);
std::fs::remove_dir_all(&base).ok();
}
}