use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use rustbrain_core::{Brain, GlobalRegistry, QueryOptions};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser)]
#[command(name = "rustbrain")]
#[command(
about = "Project-scoped, Rust-first second-brain knowledge engine for engineers and AI agents",
long_about = None,
version
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(default_value = ".")]
workspace: PathBuf,
},
Sync {
#[arg(default_value = ".")]
workspace: PathBuf,
},
Query {
query: String,
#[arg(long, default_value_t = false)]
all_workspaces: bool,
#[arg(short = 'n', long, default_value_t = 25)]
limit: usize,
#[arg(long, default_value_t = false)]
scores: bool,
#[arg(short = 'w', long, default_value = ".")]
workspace: PathBuf,
},
Context {
#[arg(short = 'p', long = "for-prompt")]
for_prompt: String,
#[arg(short = 'm', long, default_value = "2048")]
max_tokens: usize,
#[arg(long, default_value_t = 1)]
hops: usize,
#[arg(short = 'F', long, default_value = "xml")]
format: String,
#[arg(short = 'w', long, default_value = ".")]
workspace: PathBuf,
},
Watch {
#[arg(default_value = ".")]
workspace: PathBuf,
#[arg(long, default_value_t = 300)]
debounce_ms: u64,
},
Export {
#[arg(short, long)]
out: PathBuf,
#[arg(long, default_value_t = true)]
decouple_ast: bool,
#[arg(short = 'w', long, default_value = ".")]
workspace: PathBuf,
},
Import {
#[arg(short, long)]
input: PathBuf,
#[arg(short = 'w', long, default_value = ".")]
workspace: PathBuf,
},
}
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
ExitCode::FAILURE
}
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init { workspace } => {
let brain = Brain::create(&workspace)
.with_context(|| format!("failed to init brain in {}", workspace.display()))?;
println!(
"initialized rustbrain at {}",
brain.brain_dir().join("db.sqlite").display()
);
println!("nodes: {}", brain.database().count_nodes()?);
if let Ok(mut reg) = GlobalRegistry::load() {
let _ = reg.register(brain.workspace());
}
}
Commands::Sync { workspace } => {
let mut brain = Brain::open_or_create(&workspace)
.with_context(|| format!("failed to open brain in {}", workspace.display()))?;
println!("indexing workspace {} ...", brain.workspace().display());
let stats = brain.sync()?;
println!(
"sync complete: md={} canvas={} rs={} nodes_upserted={} skipped={} edges={} pending={} symbols={} mmap={}",
stats.markdown_files,
stats.canvas_files,
stats.rust_files,
stats.nodes_upserted,
stats.nodes_skipped_unchanged,
stats.edges_created,
stats.edges_pending,
stats.symbol_anchors,
stats.mmap_written
);
if let Ok(mut reg) = GlobalRegistry::load() {
let _ = reg.register(brain.workspace());
}
}
Commands::Query {
query,
all_workspaces,
limit,
scores,
workspace,
} => {
let opts = QueryOptions {
limit,
..QueryOptions::default()
};
if all_workspaces {
println!("searching all registered workspaces for '{query}' ...");
let reg = GlobalRegistry::load()?;
let results = reg.search_all_ranked(&query, &opts)?;
if results.is_empty() {
println!("no matching nodes found");
return Ok(());
}
for (idx, gh) in results.iter().enumerate() {
let node = &gh.hit.node;
if scores {
println!(
"{}. [{:.3}] [{}] {} (id: {}) @{}",
idx + 1,
gh.hit.score,
node.node_type,
node.title,
node.id,
gh.workspace
);
} else {
println!(
"{}. [{}] {} (id: {}) @{}",
idx + 1,
node.node_type,
node.title,
node.id,
gh.workspace
);
}
if let Some(p) = &node.file_path {
println!(" path: {p}");
}
}
return Ok(());
}
let brain = Brain::open(&workspace).with_context(|| {
format!(
"database not found under {}. run `rustbrain sync` first",
workspace.display()
)
})?;
println!("searching for '{query}' ...");
let results = brain.query_ranked(&query, &opts)?;
if results.is_empty() {
println!("no nodes found matching '{query}'");
} else {
for (idx, hit) in results.iter().enumerate() {
let node = &hit.node;
if scores {
println!(
"{}. [{:.3}] [{}] {} (id: {})",
idx + 1,
hit.score,
node.node_type,
node.title,
node.id
);
} else {
println!(
"{}. [{}] {} (id: {})",
idx + 1,
node.node_type,
node.title,
node.id
);
}
if let Some(path) = &node.file_path {
println!(" path: {path}");
}
if let Some(sum) = &node.summary {
println!(" summary: {sum}");
}
}
}
}
Commands::Context {
for_prompt,
max_tokens,
hops,
format,
workspace,
} => {
let brain = Brain::open(&workspace).with_context(|| {
format!(
"database not found under {}. run `rustbrain sync` first",
workspace.display()
)
})?;
let opts = rustbrain_core::ContextOptions {
max_tokens,
hop_depth: hops,
..rustbrain_core::ContextOptions::default()
};
let bundle = brain.context_for_prompt_with(&for_prompt, &opts)?;
match format.as_str() {
"markdown" | "md" => print!("{}", bundle.to_markdown()),
"xml" => print!("{}", bundle.to_xml()),
other => bail!("unknown format '{other}' (expected xml or markdown)"),
}
}
Commands::Watch {
workspace,
debounce_ms,
} => {
let brain = Brain::open_or_create(&workspace)?;
println!(
"watching {} (debounce {debounce_ms}ms); Ctrl-C to stop",
brain.workspace().display()
);
brain.watch(debounce_ms)?;
}
Commands::Export {
out,
decouple_ast,
workspace,
} => {
let brain = Brain::open(&workspace).with_context(|| {
format!(
"database not found under {}. run `rustbrain sync` first",
workspace.display()
)
})?;
println!(
"exporting to {} (decouple_ast={decouple_ast}) ...",
out.display()
);
brain.export(&out, decouple_ast)?;
println!("export complete");
}
Commands::Import { input, workspace } => {
let mut brain = Brain::open_or_create(&workspace)?;
println!("importing {} ...", input.display());
let n = brain.import(&input)?;
println!("imported {n} nodes");
}
}
Ok(())
}