use clap::{Parser, Subcommand};
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::process;
use tokensave::tokensave::TokenSave;
use tokensave::context::{format_context_as_json, format_context_as_markdown};
use tokensave::types::*;
struct Spinner {
message: std::sync::Arc<std::sync::Mutex<String>>,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
impl Spinner {
fn new() -> Self {
let message = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let msg = message.clone();
let stp = stop.clone();
let _ = write!(std::io::stderr(), "\x1b[?25l");
let _ = std::io::stderr().flush();
let handle = std::thread::spawn(move || {
let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let mut idx = 0usize;
while !stp.load(std::sync::atomic::Ordering::Relaxed) {
let text = msg.lock().unwrap().clone();
if !text.is_empty() {
let frame = frames[idx % frames.len()];
idx += 1;
let display: std::borrow::Cow<str> = if text.len() > 50 {
format!("…{}", &text[text.len() - 49..]).into()
} else {
text.as_str().into()
};
let mut stderr = std::io::stderr();
let _ = write!(stderr, "\r\x1b[2K{} {}", frame, display);
let _ = stderr.flush();
}
std::thread::sleep(std::time::Duration::from_millis(80));
}
});
Self {
message,
stop,
handle: Some(handle),
}
}
fn set_message(&self, msg: &str) {
*self.message.lock().unwrap() = msg.to_string();
}
fn done(self, message: &str) {
self.stop
.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(h) = self.handle {
let _ = h.join();
}
let mut stderr = std::io::stderr();
let _ = write!(stderr, "\x1b[?25h");
let _ = writeln!(stderr, "\r\x1b[2K\x1b[32m✔\x1b[0m {}", message);
let _ = stderr.flush();
}
}
#[derive(Parser)]
#[command(name = "tokensave", about = "Code intelligence for Rust, Go, Java, and Scala codebases")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Sync {
path: Option<String>,
#[arg(short, long)]
force: bool,
},
Status {
path: Option<String>,
#[arg(short, long)]
json: bool,
},
Query {
search: String,
#[arg(short, long)]
path: Option<String>,
#[arg(short, long, default_value = "10")]
limit: usize,
},
Context {
task: String,
#[arg(short, long)]
path: Option<String>,
#[arg(short = 'n', long, default_value = "20")]
max_nodes: usize,
#[arg(short, long, default_value = "markdown")]
format: String,
},
Serve {
#[arg(short, long)]
path: Option<String>,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
if let Err(e) = run(cli).await {
eprintln!("Error: {}", e);
process::exit(1);
}
}
async fn run(cli: Cli) -> tokensave::errors::Result<()> {
let command = match cli.command {
Some(cmd) => cmd,
None => return handle_no_command().await,
};
match command {
Commands::Sync { path, force } => {
let project_path = resolve_path(path);
if project_path.join(".codegraph").is_dir() {
eprintln!(
"warning: found legacy .codegraph/ directory at '{}'. \
tokensave now uses .tokensave/ — the old directory can be safely deleted.",
project_path.display()
);
}
if force || !TokenSave::is_initialized(&project_path) {
if !force {
eprintln!("No existing index found — performing full index");
}
init_and_index(&project_path).await?;
} else {
let cg = TokenSave::open(&project_path).await?;
let spinner = Spinner::new();
let result = cg
.sync_with_progress(|phase, detail| {
let msg = if detail.is_empty() {
phase.to_string()
} else {
format!("{phase} {detail}")
};
spinner.set_message(&msg);
})
.await?;
spinner.done(&format!(
"sync done — {} added, {} modified, {} removed in {}ms",
result.files_added,
result.files_modified,
result.files_removed,
result.duration_ms
));
}
}
Commands::Status { path, json } => {
let project_path = resolve_path(path);
let cg = ensure_initialized(&project_path).await?;
let stats = cg.get_stats().await?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&stats).unwrap_or_default()
);
} else {
let tokens_saved = cg.get_tokens_saved().await.unwrap_or(0);
print!("{}", include_str!("resources/logo.ansi"));
print_status_table(&stats, tokens_saved);
}
}
Commands::Query {
search,
path,
limit,
} => {
let project_path = resolve_path(path);
let cg = ensure_initialized(&project_path).await?;
let results = cg.search(&search, limit).await?;
if results.is_empty() {
println!("No results found for '{}'", search);
} else {
for r in &results {
println!(
"{} ({}) - {}:{}",
r.node.name,
r.node.kind.as_str(),
r.node.file_path,
r.node.start_line
);
if let Some(sig) = &r.node.signature {
println!(" {}", sig);
}
}
}
}
Commands::Context {
task,
path,
max_nodes,
format,
} => {
let project_path = resolve_path(path);
let cg = ensure_initialized(&project_path).await?;
let output_format = if format == "json" {
OutputFormat::Json
} else {
OutputFormat::Markdown
};
let options = BuildContextOptions {
max_nodes,
format: output_format.clone(),
..Default::default()
};
let context = cg.build_context(&task, &options).await?;
match output_format {
OutputFormat::Json => {
println!("{}", format_context_as_json(&context));
}
OutputFormat::Markdown => {
println!("{}", format_context_as_markdown(&context));
}
}
}
Commands::Serve { path } => {
let project_path = resolve_path(path);
let cg = ensure_initialized(&project_path).await?;
let server = tokensave::mcp::McpServer::new(cg).await;
server.run().await?;
}
}
Ok(())
}
async fn handle_no_command() -> tokensave::errors::Result<()> {
let project_path = resolve_path(None);
if TokenSave::is_initialized(&project_path) {
let _ = <Cli as clap::CommandFactory>::command().print_help();
eprintln!();
return Ok(());
}
eprint!(
"No TokenSave index found at '{}'. Create one now? [Y/n] ",
project_path.display()
);
io::stderr().flush().ok();
let mut answer = String::new();
io::stdin()
.lock()
.read_line(&mut answer)
.map_err(|e| tokensave::errors::TokenSaveError::Config {
message: format!("failed to read stdin: {}", e),
})?;
let answer = answer.trim();
if answer.is_empty() || answer.eq_ignore_ascii_case("y") {
init_and_index(&project_path).await?;
}
Ok(())
}
async fn init_and_index(project_path: &Path) -> tokensave::errors::Result<TokenSave> {
let cg = if TokenSave::is_initialized(project_path) {
TokenSave::open(project_path).await?
} else {
let cg = TokenSave::init(project_path).await?;
eprintln!("Initialized TokenSave at {}", project_path.display());
cg
};
let spinner = Spinner::new();
let result = cg.index_all_with_progress(|file| {
spinner.set_message(&format!("indexing {}", file));
}).await?;
spinner.done(&format!(
"indexing done — {} files, {} nodes, {} edges in {}ms",
result.file_count, result.node_count, result.edge_count, result.duration_ms
));
Ok(cg)
}
async fn ensure_initialized(project_path: &Path) -> tokensave::errors::Result<TokenSave> {
if TokenSave::is_initialized(project_path) {
return TokenSave::open(project_path).await;
}
Err(tokensave::errors::TokenSaveError::Config {
message: format!(
"no TokenSave index found at '{}' — run 'tokensave sync' first",
project_path.display()
),
})
}
fn format_token_count(tokens: u64) -> String {
if tokens >= 1_000_000 {
format!("{:.1}M", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{:.1}k", tokens as f64 / 1_000.0)
} else {
tokens.to_string()
}
}
fn format_bytes(bytes: u64) -> String {
if bytes >= 1_073_741_824 {
format!("{:.1} GB", bytes as f64 / 1_073_741_824.0)
} else if bytes >= 1_048_576 {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{} B", bytes)
}
}
fn format_number(n: u64) -> String {
let s = n.to_string();
let mut result = String::new();
for (i, ch) in s.chars().rev().enumerate() {
if i > 0 && i % 3 == 0 {
result.push(',');
}
result.push(ch);
}
result.chars().rev().collect()
}
fn format_cell(label: &str, value: &str, width: usize) -> String {
let content_len = label.len() + value.len();
let pad = width.saturating_sub(2 + content_len);
format!(" {}{}{} ", label, " ".repeat(pad), value)
}
fn table_separator(left: char, mid: char, right: char, cell_width: usize, num_cols: usize) -> String {
let mut line = String::from(left);
for i in 0..num_cols {
line.push_str(&"─".repeat(cell_width));
line.push(if i < num_cols - 1 { mid } else { right });
}
line
}
fn print_status_table(stats: &tokensave::types::GraphStats, tokens_saved: u64) {
let version = env!("CARGO_PKG_VERSION");
let num_cols = 3;
let mut sorted_kinds: Vec<_> = stats.nodes_by_kind.iter().collect();
sorted_kinds.sort_by_key(|(k, _)| (*k).clone());
let num_kind_rows = sorted_kinds.len().div_ceil(num_cols);
let max_kind_len = sorted_kinds
.iter()
.map(|(k, _)| k.len())
.max()
.unwrap_or(10);
let max_count_len = sorted_kinds
.iter()
.map(|(_, c)| format_number(**c).len())
.max()
.unwrap_or(5);
let cell_width = (max_kind_len + max_count_len + 3).max(22);
let inner_width = cell_width * num_cols + (num_cols - 1);
let title = format!("TokenSave v{}", version);
let tokens_text = format!("Tokens saved ~{}", format_token_count(tokens_saved));
let title_pad = inner_width.saturating_sub(2 + title.len() + tokens_text.len());
println!("{}", table_separator('╭', '─', '╮', cell_width, num_cols));
println!(
"│ {}{}\x1b[32m{}\x1b[0m │",
title,
" ".repeat(title_pad),
tokens_text
);
println!("{}", table_separator('├', '┬', '┤', cell_width, num_cols));
let mut sorted_langs: Vec<_> = stats.files_by_language.iter().collect();
sorted_langs.sort_by(|a, b| b.1.cmp(a.1));
let db_size = format_bytes(stats.db_size_bytes);
let source_size = format_bytes(stats.total_source_bytes);
let mut stats_rows: Vec<Vec<(&str, String)>> = vec![vec![
("Files", format_number(stats.file_count)),
("Nodes", format_number(stats.node_count)),
("Edges", format_number(stats.edge_count)),
]];
let mut second_row: Vec<(&str, String)> = vec![("DB Size", db_size)];
if stats.total_source_bytes > 0 {
second_row.push(("Source", source_size));
}
let mut lang_idx = 0;
while second_row.len() < num_cols && lang_idx < sorted_langs.len() {
let (lang, count) = sorted_langs[lang_idx];
second_row.push((lang.as_str(), format_number(*count)));
lang_idx += 1;
}
while second_row.len() < num_cols {
second_row.push(("", String::new()));
}
stats_rows.push(second_row);
while lang_idx < sorted_langs.len() {
let mut row: Vec<(&str, String)> = Vec::new();
for _ in 0..num_cols {
if lang_idx < sorted_langs.len() {
let (lang, count) = sorted_langs[lang_idx];
row.push((lang.as_str(), format_number(*count)));
lang_idx += 1;
} else {
row.push(("", String::new()));
}
}
stats_rows.push(row);
}
for row in &stats_rows {
print!("│");
for (i, (label, value)) in row.iter().enumerate() {
if label.is_empty() {
print!("{}", " ".repeat(cell_width));
} else {
print!("{}", format_cell(label, value, cell_width));
}
print!("{}", if i < num_cols - 1 { "│" } else { "│\n" });
}
}
if !sorted_kinds.is_empty() {
println!("{}", table_separator('├', '┼', '┤', cell_width, num_cols));
for r in 0..num_kind_rows {
print!("│");
for c in 0..num_cols {
let idx = r + c * num_kind_rows;
if idx < sorted_kinds.len() {
let (kind, count) = &sorted_kinds[idx];
print!("{}", format_cell(kind, &format_number(**count), cell_width));
} else {
print!("{}", " ".repeat(cell_width));
}
print!("{}", if c < num_cols - 1 { "│" } else { "│\n" });
}
}
}
println!("{}", table_separator('╰', '┴', '╯', cell_width, num_cols));
}
fn resolve_path(path: Option<String>) -> PathBuf {
match path {
Some(p) => PathBuf::from(p),
None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
}
}