use cli::{
format_backup, format_demo, format_stats, format_suggest, install, maybe_run_demo_if_empty,
parse_args, read_stats, run_algo, run_asof, run_backup, run_build_index, run_demo, run_export,
run_migrate, run_query, run_schema_apply, run_snapshot, run_suggest, run_verify, usage,
Command, ServeUi,
};
use core_api::{GraphError, SharedDb};
use std::collections::HashMap;
use std::io::{self, Read as _, Write};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
const DEFAULT_LOG_FILTER: &str = "mushroomdb=info,server=info,cli=info,warn";
fn init_logging() {
use tracing_subscriber::{fmt, EnvFilter};
let filter = EnvFilter::try_from_env("MUSHROOMDB_LOG")
.unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER));
let _ = fmt()
.with_env_filter(filter)
.with_target(false)
.with_writer(std::io::stderr)
.try_init();
}
fn main() -> ExitCode {
let raw: Vec<String> = std::env::args().skip(1).collect();
match parse_args(&raw) {
Ok(Command::Help) => {
print!("{}", usage());
ExitCode::SUCCESS
}
Ok(Command::Recall { db_dir, auto }) => {
let mut raw = String::new();
let _ = io::stdin().read_to_string(&mut raw);
let digest = silently(|| cli::recall::run_recall(&resolve_db(db_dir, auto), &raw))
.unwrap_or_default();
let mut stdout = io::stdout();
let _ = stdout.write_all(digest.as_bytes());
let _ = stdout.flush();
ExitCode::SUCCESS }
Ok(Command::Brief { db_dir, auto }) => {
let brief = silently(|| cli::run_brief(&resolve_db(db_dir, auto)))
.and_then(Result::ok)
.unwrap_or_default();
let mut stdout = io::stdout();
let _ = stdout.write_all(brief.as_bytes());
let _ = stdout.flush();
ExitCode::SUCCESS
}
Ok(Command::Intercept { db_dir, auto }) => {
let mut raw = String::new();
let _ = io::stdin().read_to_string(&mut raw);
match silently(|| cli::intercept::run_intercept(&resolve_db(db_dir, auto), &raw))
.flatten()
{
Some(message) => {
let mut stderr = io::stderr();
let _ = writeln!(stderr, "{message}");
let _ = stderr.flush();
ExitCode::from(2)
}
None => ExitCode::SUCCESS,
}
}
Ok(Command::ImpactHook { db_dir, auto }) => {
let mut raw = String::new();
let _ = io::stdin().read_to_string(&mut raw);
let text =
silently(|| cli::impact_hook::run(&resolve_db(db_dir, auto), &raw)).flatten();
print_hook_context("PreToolUse", text.as_deref());
ExitCode::SUCCESS
}
Ok(Command::Enrich { db_dir, auto }) => {
let mut raw = String::new();
let _ = io::stdin().read_to_string(&mut raw);
let text = silently(|| cli::enrich::run(&resolve_db(db_dir, auto), &raw)).flatten();
print_hook_context("PostToolUse", text.as_deref());
ExitCode::SUCCESS
}
Ok(Command::Map { db_dir, json }) => match cli::run_map(&db_dir, json) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::Explore {
db_dir,
target,
depth,
full,
}) => print_or_fail(cli::run_explore(&db_dir, &target, depth, full)),
Ok(Command::Context {
db_dir,
target,
full,
}) => print_or_fail(cli::run_context(&db_dir, &target, full)),
Ok(Command::Impact { db_dir, files }) => print_or_fail(cli::run_impact(&db_dir, &files)),
Ok(Command::Owners { db_dir, path }) => print_or_fail(cli::run_owners(&db_dir, &path)),
Ok(Command::Why { db_dir, a, b }) => print_or_fail(cli::run_why(&db_dir, &a, &b)),
Ok(Command::Sync { db_dir, auto, json }) => {
match cli::ingest_git::run_sync(&resolve_db(db_dir, auto)) {
Ok(report) => {
if json {
print!("{}", cli::ingest_git::format_sync_json(&report));
} else {
print!("{}", cli::ingest_git::format_sync(&report));
}
ExitCode::SUCCESS
}
Err(e) => busy_aware(&e),
}
}
Ok(Command::Touch {
db_dir,
auto,
files,
}) => {
let payload = if files.is_empty() {
let mut raw = String::new();
let _ = io::stdin().read_to_string(&mut raw);
Some(raw)
} else {
None
};
if files.is_empty() || auto {
silent_touch(db_dir, auto, &files, payload.as_deref());
return ExitCode::SUCCESS;
}
match cli::ingest_git::run_touch(&resolve_db(db_dir, auto), &files, payload.as_deref())
{
Ok(report) => {
print!("{}", cli::ingest_git::format_touch(&report));
ExitCode::SUCCESS
}
Err(e) => busy_aware(&e),
}
}
Ok(Command::Version) => {
println!("{}", cli::version_string());
ExitCode::SUCCESS
}
Ok(Command::IngestGit { db_dir, opts }) => {
match cli::ingest_git::run_ingest_git(&db_dir, &opts) {
Ok(report) => {
print!("{}", cli::ingest_git::format_ingest_git(&report));
ExitCode::SUCCESS
}
Err(e) => busy_aware(&e),
}
}
Ok(Command::Serve {
db_dir,
addr,
ui,
demo_if_empty,
token,
role_tokens,
snapshot_every,
restore_from,
tls_cert,
tls_key,
}) => {
let token = token.filter(|s| !s.is_empty()).or_else(|| {
std::env::var("MUSHROOMDB_TOKEN")
.ok()
.filter(|s| !s.is_empty())
});
if !addr.ip().is_loopback() && token.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
return fail(
"non-loopback --addr requires --token or MUSHROOMDB_TOKEN \
(see SECURITY.md)",
);
}
let mut all_role_tokens: HashMap<String, String> = HashMap::new();
if let Ok(env_val) = std::env::var("MUSHROOMDB_ROLE_TOKENS") {
for pair in env_val.split(',') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
if let Some((tok, role)) = pair.split_once(':') {
if !tok.is_empty() && !role.is_empty() {
all_role_tokens.insert(tok.to_string(), role.to_string());
}
}
}
}
for (tok, role) in role_tokens {
all_role_tokens.insert(tok, role);
}
if let Some(from) = restore_from {
match cli::restore_if_empty(&db_dir, &from) {
Ok(cli::RestoreOutcome::Restored { from, files, bytes }) => println!(
"restored from {}: {} files, {} bytes",
from.display(),
files.len(),
bytes
),
Ok(cli::RestoreOutcome::AlreadyPresent) => eprintln!(
"restore-from: {} already holds a store; not restoring",
db_dir.display()
),
Ok(cli::RestoreOutcome::Empty) => {
eprintln!("restore-from: no backup found under {}", from.display())
}
Err(e) => return fail(&e.to_string()),
}
}
if demo_if_empty {
match maybe_run_demo_if_empty(&db_dir) {
Ok(Some(out)) => print!("{}", format_demo(&db_dir, &out)),
Ok(None) => {}
Err(e) => return fail(&e.to_string()),
}
}
let ui = match ui {
ServeUi::Filesystem(dir) => match cli::validate_ui_dir(&dir) {
Ok(dir) => ServeUi::Filesystem(dir),
Err(e) => return fail(&e),
},
other => other,
};
exit(run_serve(
db_dir,
addr,
ui,
token,
all_role_tokens,
snapshot_every,
tls_cert,
tls_key,
))
}
Ok(Command::Mcp {
db_dir,
auto,
all_tools,
}) => exit(run_mcp(resolve_db(db_dir, auto), all_tools)),
Ok(Command::Stats { db_dir }) => match read_stats(&db_dir) {
Ok(stats) => {
print!("{}", format_stats(&stats));
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::Demo { db_dir }) => match run_demo(&db_dir) {
Ok(out) => {
print!("{}", format_demo(&db_dir, &out));
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::Suggest { db_dir }) => match run_suggest(&db_dir) {
Ok(suggestions) => {
print!("{}", format_suggest(&suggestions));
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::AsOf {
db_dir,
commit,
at,
query,
namespace,
}) => match run_asof(
&db_dir,
commit,
at.as_deref(),
query.as_deref(),
namespace.as_deref(),
) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::Algo {
db_dir,
subcmd,
top,
dir,
edge_types,
weight_prop,
min_weight,
}) => match run_algo(
&db_dir,
&subcmd,
top,
dir,
edge_types,
weight_prop,
min_weight,
) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::Query {
db_dir,
cypher,
role,
namespace,
}) => match run_query(&db_dir, &cypher, role.as_deref(), namespace.as_deref()) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::Snapshot {
db_dir,
wal,
retention,
}) => match run_snapshot(&db_dir, wal, retention) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::BuildIndex { db_dir, rule }) => {
match run_build_index(&db_dir, rule.as_deref()) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
}
}
Ok(Command::SchemaApply {
db_dir,
schema_file,
}) => match run_schema_apply(&db_dir, &schema_file) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::Migrate { db_dir }) => match run_migrate(&db_dir) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::Verify { db_dir }) => match run_verify(&db_dir) {
Ok(msg) => {
println!("{msg}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("error: {}", e);
ExitCode::from(1)
}
},
Ok(Command::Backup { db_dir, dest }) => match run_backup(&db_dir, &dest) {
Ok(report) => {
print!("{}", format_backup(&dest, &report));
if !report.verified {
eprintln!("warning: backup verification failed");
return ExitCode::from(1);
}
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::Export {
db_dir,
dest,
format,
}) => match run_export(&db_dir, &dest, &format) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
},
Ok(Command::Install(opts)) => {
let home = home_dir();
let cwd = std::env::current_dir().unwrap_or_default();
match install::run_install(&cwd, &home, &opts) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
}
}
Ok(Command::Uninstall(opts)) => {
let home = home_dir();
let cwd = std::env::current_dir().unwrap_or_default();
match install::run_uninstall(&cwd, &home, &opts) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
}
}
Ok(Command::Disable(opts)) => {
let home = home_dir();
let cwd = std::env::current_dir().unwrap_or_default();
match install::run_disable(&cwd, &home, &opts) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
}
}
Ok(Command::Enable(opts)) => {
let home = home_dir();
let cwd = std::env::current_dir().unwrap_or_default();
match install::run_enable(&cwd, &home, &opts) {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
}
}
Ok(Command::Doctor(opts)) => {
let home = home_dir();
let cwd = std::env::current_dir().unwrap_or_default();
match cli::doctor::run_doctor(&cwd, &home, &opts) {
Ok(report) => {
print!("{}", report.output);
if report.had_fail {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
Err(e) => fail(&e.to_string()),
}
}
Err(e) => {
let _ = writeln!(io::stderr(), "{e}");
eprint!("{}", usage());
ExitCode::from(1)
}
}
}
fn exit(r: Result<(), String>) -> ExitCode {
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => fail(&e),
}
}
fn print_or_fail(r: Result<String, cli::CliError>) -> ExitCode {
match r {
Ok(out) => {
print!("{out}");
ExitCode::SUCCESS
}
Err(e) => fail(&e.to_string()),
}
}
fn fail(msg: &str) -> ExitCode {
let _ = writeln!(io::stderr(), "{msg}");
ExitCode::from(1)
}
fn busy_aware(e: &cli::CliError) -> ExitCode {
let _ = writeln!(io::stderr(), "error: {e}");
if e.0 == cli::ingest_git::BUSY_MESSAGE {
ExitCode::from(3)
} else {
ExitCode::FAILURE
}
}
fn silently<T>(f: impl FnOnce() -> T) -> Option<T> {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
std::panic::set_hook(previous);
outcome.ok()
}
fn silent_touch(db_dir: Option<PathBuf>, auto: bool, files: &[PathBuf], payload: Option<&str>) {
let _ = silently(|| {
let db = resolve_db(db_dir, auto);
cli::ingest_git::run_touch(&db, files, payload)
});
}
fn print_hook_context(event: &str, text: Option<&str>) {
let Some(text) = text.filter(|t| !t.is_empty()) else {
return;
};
let out = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": event,
"additionalContext": text,
}
});
let mut stdout = io::stdout();
let _ = stdout.write_all(out.to_string().as_bytes());
let _ = stdout.flush();
}
fn resolve_db(db_dir: Option<PathBuf>, auto: bool) -> PathBuf {
match db_dir {
Some(dir) => dir,
None => {
debug_assert!(auto, "the parser rejects neither a dir nor --auto");
cli::resolve_auto_db(
std::env::var_os("CLAUDE_PROJECT_DIR").as_deref(),
&std::env::current_dir().unwrap_or_default(),
&home_dir(),
)
}
}
}
#[allow(clippy::too_many_arguments)]
fn run_serve(
db_dir: PathBuf,
addr: SocketAddr,
ui: ServeUi,
token: Option<String>,
role_tokens: HashMap<String, String>,
snapshot_every: Option<Duration>,
tls_cert: Option<PathBuf>,
tls_key: Option<PathBuf>,
) -> Result<(), String> {
let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
rt.block_on(async {
let db = SharedDb::open(&db_dir).map_err(|e| e.to_string())?;
let (tx, rx) = tokio::sync::oneshot::channel();
if let Some(period) = snapshot_every {
let db_snap = db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(period);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await; loop {
interval.tick().await;
let db_snap = db_snap.clone();
let taken =
tokio::task::spawn_blocking(move || cli::snapshot_shared(&db_snap)).await;
match taken {
Ok(Ok(())) => {}
Ok(Err(GraphError::Busy { .. })) => {
eprintln!(
"snapshot-every skipped: another process holds the write lock"
);
}
Ok(Err(e)) => eprintln!("snapshot-every failed: {e}"),
Err(e) => eprintln!("snapshot-every task panicked: {e}"),
}
}
});
}
{
let db_build = db.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await; let mut last: Vec<(String, u64)> = Vec::new();
loop {
interval.tick().await;
if db_build.read().builds_in_progress().is_empty() {
continue;
}
let db_build = db_build.clone();
let pumped =
tokio::task::spawn_blocking(move || db_build.write().pump_index_build())
.await;
let now = match pumped {
Ok(Ok(v)) => v,
Ok(Err(GraphError::Busy { .. })) => continue,
Ok(Err(e)) => {
eprintln!("build-index failed: {e}");
continue;
}
Err(e) => {
eprintln!("build-index task panicked: {e}");
continue;
}
};
for (rule, total) in &last {
if !now.iter().any(|b| &b.rule == rule) {
eprintln!("built {rule}: {total} vectors");
}
}
for b in &now {
if last.iter().any(|(r, _)| r == &b.rule) {
eprintln!("building {}: {}/{}", b.rule, b.indexed, b.total);
}
}
last = now.into_iter().map(|b| (b.rule, b.total)).collect();
}
});
}
init_logging();
let db_serve = db.clone();
let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
let mut serve = tokio::spawn(async move {
if let (Some(cert), Some(key)) = (tls_cert, tls_key) {
#[cfg(feature = "tls")]
{
return server::serve_tls_with_shutdown(
db_serve,
addr,
tx,
cert,
key,
token,
role_tokens,
stop_rx,
)
.await;
}
#[cfg(not(feature = "tls"))]
{
let _ = (cert, key, db_serve, addr, tx, token, role_tokens, stop_rx);
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"this binary was built without TLS support; \
rebuild with --features tls or terminate TLS at a \
reverse proxy (see docs/site/deployment.md)",
));
}
}
match ui {
ServeUi::Filesystem(dir) => {
server::serve_with_ui_and_role_tokens_and_shutdown(
db_serve,
addr,
tx,
dir,
token,
role_tokens,
stop_rx,
)
.await
}
ServeUi::None => {
server::serve_with_role_tokens_and_shutdown(
db_serve,
addr,
tx,
token,
role_tokens,
stop_rx,
)
.await
}
ServeUi::Embedded => {
#[cfg(feature = "embed-ui")]
{
server::serve_with_embedded_ui_and_shutdown(
db_serve,
addr,
tx,
token,
role_tokens,
stop_rx,
)
.await
}
#[cfg(not(feature = "embed-ui"))]
{
server::serve_with_role_tokens_and_shutdown(
db_serve,
addr,
tx,
token,
role_tokens,
stop_rx,
)
.await
}
}
}
});
match rx.await {
Ok(bound) => {
println!("listening on http://{bound}");
tracing::info!(addr = %bound, "listening");
}
Err(_) => {
return match serve.await {
Ok(Ok(())) => Err("server exited before readiness".into()),
Ok(Err(e)) => Err(e.to_string()),
Err(e) => Err(e.to_string()),
};
}
}
tokio::select! {
result = &mut serve => match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(e.to_string()),
Err(e) => Err(e.to_string()),
},
_ = shutdown_signal() => {
tracing::info!("shutdown signal received; draining in-flight requests");
let _ = stop_tx.send(());
match (&mut serve).await {
Ok(Ok(())) => tracing::info!("drained; stopped"),
Ok(Err(e)) => tracing::error!(error = %e, "server exited with an error"),
Err(e) => tracing::error!(error = %e, "server task failed"),
}
match cli::snapshot_shared(&db) {
Ok(()) => {}
Err(GraphError::Busy { .. }) => {
eprintln!(
"shutdown snapshot skipped: another process holds the write lock"
);
}
Err(e) => return Err(e.to_string()),
}
Ok(())
}
}
})
}
async fn shutdown_signal() {
let ctrl_c = async {
match tokio::signal::ctrl_c().await {
Ok(()) => {}
Err(_) => std::future::pending::<()>().await,
}
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sig) => {
let _ = sig.recv().await;
}
Err(_) => std::future::pending::<()>().await,
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {}
_ = terminate => {}
}
}
fn run_mcp(db_dir: PathBuf, all_tools: bool) -> Result<(), String> {
let db = SharedDb::open(&db_dir).map_err(|e| e.to_string())?;
let stdin = io::stdin();
let stdout = io::stdout();
server::run_mcp_stdio_with(db, Some(db_dir), all_tools, stdin.lock(), stdout.lock())
.map_err(|e| e.to_string())
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"))
}
#[cfg(test)]
mod tests {
use super::DEFAULT_LOG_FILTER;
#[test]
fn default_filter_names_the_servers_real_log_target() {
let root = server::LOG_TARGET_ROOT;
assert!(
DEFAULT_LOG_FILTER.contains(&format!("{root}=")),
"the default filter {DEFAULT_LOG_FILTER:?} does not name the \
server's log target {root:?}, so nothing it logs would appear"
);
}
#[test]
fn default_filter_parses() {
use tracing_subscriber::EnvFilter;
let parsed = EnvFilter::builder().parse(DEFAULT_LOG_FILTER);
assert!(parsed.is_ok(), "default filter does not parse: {parsed:?}");
}
}