use std::io::Read;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{anyhow, Result};
use clap::{Parser, Subcommand, ValueEnum};
use tessera_codegraph::bench::{self, BenchOptions};
use tessera_codegraph::completions::{self, CompletionShell};
use tessera_codegraph::db;
use tessera_codegraph::doctor::{self, DoctorOptions};
use tessera_codegraph::indexer::{self, IndexOptions};
use tessera_codegraph::init::{self, InitOptions};
use tessera_codegraph::mcp;
use tessera_codegraph::mcp_http;
use tessera_codegraph::query;
use tessera_codegraph::snapshot;
use tessera_codegraph::types::{GraphEngineKind, Language, SearchOptions, UnusedOptions};
use tessera_codegraph::watch::{self, WatchOptions};
use tessera_codegraph::{ExportGroupBy, ExportOptions};
#[derive(Debug, Parser)]
#[command(name = "tessera")]
#[command(version)]
#[command(about = "Semantic code graph and MCP server for AI coding agents")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Clone, ValueEnum)]
enum LangArg {
Typescript,
Tsx,
Javascript,
Python,
Go,
Rust,
Java,
C,
Cpp,
Csharp,
Ruby,
Php,
}
impl From<LangArg> for Language {
fn from(value: LangArg) -> Self {
match value {
LangArg::Typescript => Language::TypeScript,
LangArg::Tsx => Language::Tsx,
LangArg::Javascript => Language::JavaScript,
LangArg::Python => Language::Python,
LangArg::Go => Language::Go,
LangArg::Rust => Language::Rust,
LangArg::Java => Language::Java,
LangArg::C => Language::C,
LangArg::Cpp => Language::Cpp,
LangArg::Csharp => Language::CSharp,
LangArg::Ruby => Language::Ruby,
LangArg::Php => Language::Php,
}
}
}
#[derive(Debug, Clone, ValueEnum, Default)]
enum GraphFormat {
#[default]
Mermaid,
Dot,
}
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
enum GraphGroupArg {
#[default]
None,
File,
Directory,
Language,
}
impl From<GraphGroupArg> for ExportGroupBy {
fn from(value: GraphGroupArg) -> Self {
match value {
GraphGroupArg::None => ExportGroupBy::None,
GraphGroupArg::File => ExportGroupBy::File,
GraphGroupArg::Directory => ExportGroupBy::Directory,
GraphGroupArg::Language => ExportGroupBy::Language,
}
}
}
impl GraphFormat {
fn as_str(&self) -> &'static str {
match self {
GraphFormat::Mermaid => "mermaid",
GraphFormat::Dot => "dot",
}
}
}
#[derive(Debug, Clone, ValueEnum, Default)]
enum EngineArg {
#[default]
Sqlite,
Cozo,
}
impl From<EngineArg> for GraphEngineKind {
fn from(value: EngineArg) -> Self {
match value {
EngineArg::Sqlite => GraphEngineKind::Sqlite,
EngineArg::Cozo => GraphEngineKind::Cozo,
}
}
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CompletionShellArg {
Bash,
Zsh,
Fish,
Powershell,
}
impl From<CompletionShellArg> for CompletionShell {
fn from(value: CompletionShellArg) -> Self {
match value {
CompletionShellArg::Bash => CompletionShell::Bash,
CompletionShellArg::Zsh => CompletionShell::Zsh,
CompletionShellArg::Fish => CompletionShell::Fish,
CompletionShellArg::Powershell => CompletionShell::Powershell,
}
}
}
#[derive(Debug, Subcommand)]
enum Commands {
Index {
path: PathBuf,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
full: bool,
#[arg(long)]
no_snapshot: bool,
#[arg(long, value_enum, default_value_t = EngineArg::Sqlite)]
graph_engine: EngineArg,
},
Watch {
path: PathBuf,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
full: bool,
#[arg(long)]
no_snapshot: bool,
#[arg(long, default_value_t = 500)]
poll_ms: u64,
#[arg(long, default_value_t = 250)]
debounce_ms: u64,
#[arg(long)]
once: bool,
},
Doctor {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
Init {
#[arg(default_value = ".")]
root: PathBuf,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
git_hooks: bool,
#[arg(long)]
mcp_configs: bool,
#[arg(long)]
force: bool,
#[arg(long)]
json: bool,
},
Completions {
#[arg(value_enum)]
shell: CompletionShellArg,
},
FindDefinition {
symbol: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
FindReferences {
symbol: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
GetOutline {
path: PathBuf,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
ExpandSymbol {
symbol: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
Impact {
symbol: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long, default_value_t = 4)]
depth: usize,
#[arg(long, alias = "why")]
explain: bool,
#[arg(long)]
json: bool,
},
Validate {
symbol: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long, alias = "why")]
explain: bool,
#[arg(long)]
json: bool,
},
ValidateSnippet {
#[arg(long, value_enum)]
language: LangArg,
#[arg(long)]
file: Option<PathBuf>,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
Stats {
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
Search {
pattern: String,
#[arg(long, value_delimiter = ',')]
kind: Vec<String>,
#[arg(long, value_delimiter = ',')]
language: Vec<String>,
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
exported: Option<bool>,
#[arg(long)]
path: Option<String>,
#[arg(long, default_value_t = 50)]
limit: usize,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long, alias = "why")]
explain: bool,
#[arg(long)]
json: bool,
},
Unused {
#[arg(long, value_delimiter = ',')]
kind: Vec<String>,
#[arg(long, value_delimiter = ',')]
language: Vec<String>,
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
exported: Option<bool>,
#[arg(long)]
path: Option<String>,
#[arg(long, default_value_t = 50)]
limit: usize,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long, alias = "why")]
explain: bool,
#[arg(long)]
json: bool,
},
ContextPack {
symbol: String,
#[arg(long, default_value_t = 1500)]
budget: usize,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
PlanQuery {
task: String,
#[arg(long)]
symbol: Option<String>,
#[arg(long)]
json: bool,
},
EditPrep {
symbol: String,
#[arg(long, default_value_t = 1800)]
budget: usize,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
DiffImpact {
from: String,
#[arg(long)]
to: Option<String>,
#[arg(long, default_value_t = 3)]
depth: usize,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
Imports {
path: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
ImportedBy {
source: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
Signature {
symbol: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
Siblings {
symbol: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
TestsFor {
symbol: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
Connect {
from: String,
to: String,
#[arg(long, default_value_t = 8)]
depth: usize,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
Export {
#[arg(long, value_enum, default_value_t = GraphFormat::Mermaid)]
format: GraphFormat,
#[arg(long)]
from: Option<String>,
#[arg(long, default_value_t = 3)]
depth: usize,
#[arg(long, default_value_t = 800)]
limit: usize,
#[arg(long, value_enum, default_value_t = GraphGroupArg::None)]
group_by: GraphGroupArg,
#[arg(long)]
collapse_tests: bool,
#[arg(long)]
exported_only: bool,
#[arg(long)]
html_out: Option<PathBuf>,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
#[arg(long)]
json: bool,
},
Bench {
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
probe: Option<String>,
#[arg(long, default_value_t = 50)]
scale: usize,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Snapshot {
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
},
Mcp {
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
},
McpHttp {
#[arg(long, default_value = "127.0.0.1:8765")]
addr: String,
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
},
Shell {
#[arg(long, default_value = ".tessera/tessera.db")]
db: PathBuf,
},
}
fn main() -> Result<()> {
#[cfg(windows)]
{
return std::thread::Builder::new()
.name("tessera-main".to_string())
.stack_size(8 * 1024 * 1024)
.spawn(run)?
.join()
.map_err(|_| anyhow!("tessera main thread panicked"))?;
}
#[cfg(not(windows))]
run()
}
fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Index {
path,
db,
full,
no_snapshot,
graph_engine,
} => {
let engine: GraphEngineKind = graph_engine.into();
if matches!(engine, GraphEngineKind::Cozo) && !cfg!(feature = "cozo") {
return Err(anyhow!(
"Cozo backend not compiled in. Rebuild with `cargo install --features cozo`."
));
}
let options = IndexOptions {
full,
build_snapshot: !no_snapshot,
};
let report = indexer::index_path_with(&path, &db, options)?;
let mode = match report.mode {
indexer::IndexMode::Full => "full",
indexer::IndexMode::Incremental => "incremental",
};
println!(
"[{mode}] indexed {} files (+{} reused, -{} removed), {} symbols, {} references into {} in {}ms",
report.files_indexed,
report.files_reused,
report.files_removed,
report.symbols_indexed,
report.references_indexed,
db.display(),
report.elapsed_ms
);
print_index_warnings(&report.warnings);
}
Commands::Watch {
path,
db,
full,
no_snapshot,
poll_ms,
debounce_ms,
once,
} => {
let options = WatchOptions {
poll_interval: Duration::from_millis(poll_ms),
debounce: Duration::from_millis(debounce_ms),
index_options: IndexOptions {
full,
build_snapshot: !no_snapshot,
},
once,
};
watch::watch_path(&path, &db, options)?;
}
Commands::Doctor { root, db, json } => {
let result = doctor::run(DoctorOptions { root, db_path: db })?;
print_result(result, json)?;
}
Commands::Init {
root,
db,
git_hooks,
mcp_configs,
force,
json,
} => {
let result = init::run(InitOptions {
root,
db_path: db,
git_hooks,
mcp_configs,
force,
})?;
print_result(result, json)?;
}
Commands::Completions { shell } => {
print!("{}", completions::generate(shell.into()));
}
Commands::FindDefinition { symbol, db, json } => {
print_result(query::find_definition(&db, &symbol)?, json)?;
}
Commands::FindReferences { symbol, db, json } => {
print_result(query::find_references(&db, &symbol)?, json)?;
}
Commands::GetOutline { path, db, json } => {
print_result(query::get_outline(&db, &path)?, json)?;
}
Commands::ExpandSymbol { symbol, db, json } => {
print_result(query::expand_symbol(&db, &symbol)?, json)?;
}
Commands::Impact {
symbol,
db,
depth,
explain,
json,
} => {
let result = query::impact(&db, &symbol, depth)?;
print_result(&result, json)?;
if explain && !json {
explain_impact(&result);
}
}
Commands::Validate {
symbol,
db,
explain,
json,
} => {
let result = query::validate(&db, &symbol)?;
print_result(&result, json)?;
if explain && !json {
explain_validate(&result);
}
}
Commands::ValidateSnippet {
language,
file,
db,
json,
} => {
let code = match file {
Some(path) => std::fs::read_to_string(path)?,
None => {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
}
};
let result = query::validate_snippet(&db, &code, language.into())?;
print_result(result, json)?;
}
Commands::Stats { db, json } => {
print_result(query::stats(&db)?, json)?;
}
Commands::TestsFor { symbol, db, json } => {
print_result(query::tests_for(&db, &symbol)?, json)?;
}
Commands::Connect {
from,
to,
depth,
db,
json,
} => {
print_result(query::connect(&db, &from, &to, depth)?, json)?;
}
Commands::Export {
format,
from,
depth,
limit,
group_by,
collapse_tests,
exported_only,
html_out,
db,
json,
} => {
let mut options = ExportOptions::new(format.as_str(), from.as_deref(), depth, limit);
options.group_by = group_by.into();
options.collapse_tests = collapse_tests;
options.exported_only = exported_only;
options.html_out = html_out;
print_result(query::export_with_options(&db, options)?, json)?;
}
Commands::ContextPack {
symbol,
budget,
db,
json,
} => {
print_result(query::context_pack(&db, &symbol, budget)?, json)?;
}
Commands::PlanQuery { task, symbol, json } => {
print_result(query::plan_query(&task, symbol.as_deref()), json)?;
}
Commands::EditPrep {
symbol,
budget,
db,
json,
} => {
print_result(query::edit_prep(&db, &symbol, budget)?, json)?;
}
Commands::DiffImpact {
from,
to,
depth,
db,
json,
} => {
print_result(query::diff_impact(&db, &from, to.as_deref(), depth)?, json)?;
}
Commands::Imports { path, db, json } => {
print_result(query::imports(&db, &path)?, json)?;
}
Commands::ImportedBy { source, db, json } => {
print_result(query::imported_by(&db, &source)?, json)?;
}
Commands::Signature { symbol, db, json } => {
print_result(query::signature(&db, &symbol)?, json)?;
}
Commands::Siblings { symbol, db, json } => {
print_result(query::siblings(&db, &symbol)?, json)?;
}
Commands::Search {
pattern,
kind,
language,
exported,
path,
limit,
db,
explain,
json,
} => {
let options = SearchOptions {
kinds: kind,
languages: language,
exported,
path_prefix: path,
limit,
};
let result = query::search(&db, &pattern, options)?;
print_result(&result, json)?;
if explain && !json {
explain_search(&result);
}
}
Commands::Unused {
kind,
language,
exported,
path,
limit,
db,
explain,
json,
} => {
let options = UnusedOptions {
kinds: kind,
languages: language,
exported,
path_prefix: path,
limit,
};
let result = query::unused(&db, options)?;
print_result(&result, json)?;
if explain && !json {
explain_unused(&result);
}
}
Commands::Bench {
path,
probe,
scale,
out,
json,
} => {
let result = bench::run(BenchOptions {
path,
probe_symbol: probe,
scale: Some(scale),
})?;
if let Some(out_path) = out {
std::fs::write(&out_path, &result.chart)?;
}
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("{}", result.chart);
}
}
Commands::Snapshot { db } => {
let conn = db::open(&db)?;
let snapshot_path = db
.parent()
.map(|p| p.join("snapshot.bin"))
.unwrap_or_else(|| PathBuf::from("snapshot.bin"));
snapshot::build(&conn, &snapshot_path)?;
println!("snapshot written to {}", snapshot_path.display());
}
Commands::Mcp { db } => mcp::serve_stdio(&db)?,
Commands::McpHttp { addr, db } => mcp_http::serve(&addr, &db)?,
Commands::Shell { db } => query::shell(&db)?,
}
Ok(())
}
fn print_result<T>(value: T, json: bool) -> Result<()>
where
T: serde::Serialize + std::fmt::Display,
{
if json {
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
println!("{value}");
}
Ok(())
}
fn explain_impact(result: &tessera_codegraph::types::ImpactResult) {
println!("\nWhy:");
println!(" Impact walks the reverse call graph: callee -> transitive callers.");
println!(" Scores combine personalised PageRank, caller fan-in/out, exported status, test-path coverage, and depth decay.");
if let Some(top) = result.callers.first() {
println!(
" Top caller `{}` ranked first with pagerank {:.4}, fanout_in {}, fanout_out {}, exported={}, depth_decay {:.2}.",
top.symbol.qualified_name,
top.breakdown.pagerank,
top.breakdown.fanout_in,
top.breakdown.fanout_out,
top.breakdown.exported,
top.breakdown.depth_decay
);
}
}
fn explain_validate(result: &tessera_codegraph::types::ValidateResult) {
println!("\nWhy:");
println!(" Validate checks the Bloom filter first; a miss means the symbol is definitely absent from the indexed names.");
println!(" If unresolved, candidates come from trigram search and are ranked by Jaro-Winkler similarity.");
println!(
" bloom_hit={} candidates={}",
result.bloom_hit,
result.candidates.len()
);
}
fn explain_search(result: &tessera_codegraph::types::SearchResult) {
println!("\nWhy:");
if result.query.contains('*') {
println!(" Search treated the pattern as a glob and ranked matching names deterministically by match quality and qualified name.");
} else {
println!(" Search used trigram fuzzy lookup and ranked hits by Jaro-Winkler similarity against name and qualified_name.");
}
println!(" Returned hits are also filtered by any kind, language, exported, path, and limit options supplied.");
}
fn explain_unused(result: &tessera_codegraph::types::UnusedResult) {
println!("\nWhy:");
println!(" A symbol is reported unused only when both inbound refs and inbound call edges are zero for its simple and qualified names.");
println!(
" Test-path symbols are excluded, then kind/language/exported/path filters are applied."
);
if let Some(first) = result.symbols.first() {
println!(
" `{}` has inbound_refs={} and inbound_edges={}.",
first.symbol.qualified_name, first.inbound_refs, first.inbound_edges
);
}
}
fn print_index_warnings(warnings: &[indexer::IndexWarning]) {
if warnings.is_empty() {
return;
}
eprintln!("indexed with {} warning(s):", warnings.len());
for warning in warnings.iter().take(5) {
eprintln!(" {}: {}", warning.path, warning.message);
}
if warnings.len() > 5 {
eprintln!(" ... {} more", warnings.len() - 5);
}
}