use std::fmt::Write as _;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, RwLock};
use anyhow::Context;
use clap::{Parser, Subcommand};
use dotenvy::dotenv;
use tracing_subscriber::{fmt, EnvFilter};
macro_rules! handle_agent_error {
($result:expr, $format:expr) => {
match $result {
Ok(value) => value,
Err(e) if $format == OutputFormat::Agent => {
let (error_type, suggestion, exit_code) = classify_error(&e);
handle_agent_error(&e, &$format, &error_type, &suggestion, exit_code);
}
Err(e) => return Err(e),
}
};
}
use maproom::cli::format::{
format_agent_error, format_context_agent, format_hits_agent, format_hits_json_search,
format_hits_json_vector, sanitize_newlines, OutputFormat, SearchMetadata,
};
use maproom::context::{AssemblyStrategy, ContextBundle, DefaultAssemblyStrategy, ExpandOptions};
use maproom::db::StoreCleanup;
use maproom::db::StoreCore;
use maproom::db::StoreEmbeddings;
use maproom::db::StoreIndexState;
use maproom::db::StoreSearch;
use maproom::progress::{OutputMode, ProgressTracker};
use maproom::{daemon, db, indexer};
const EXIT_RUNTIME_ERROR: i32 = 1;
const EXIT_CONFIG_ERROR: i32 = 2;
fn validate_provider(s: &str) -> Result<String, String> {
match s.to_lowercase().as_str() {
"ollama" | "openai" | "google" => Ok(s.to_lowercase()),
_ => Err(format!(
"Invalid provider: '{}'. Supported providers: ollama, openai, google",
s
)),
}
}
fn deduplicate_search_hits(hits: Vec<db::SearchHit>, limit: usize) -> Vec<db::SearchHit> {
use std::collections::HashMap;
if hits.is_empty() {
return hits;
}
let mut groups: HashMap<(String, Option<String>, i32), Vec<db::SearchHit>> = HashMap::new();
for hit in hits {
let key = (
hit.file_relpath.clone(),
hit.symbol_name.clone(),
hit.start_line,
);
groups.entry(key).or_default().push(hit);
}
let mut deduped: Vec<db::SearchHit> = groups
.into_values()
.map(|mut group| {
group.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
group.remove(0)
})
.collect();
deduped.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
deduped.into_iter().take(limit).collect()
}
fn format_number(n: i64) -> String {
let s = n.to_string();
let mut result = String::new();
let chars: Vec<char> = s.chars().collect();
for (i, ch) in chars.iter().enumerate() {
if i > 0 && (chars.len() - i) % 3 == 0 {
result.push(',');
}
result.push(*ch);
}
result
}
fn get_short_commit_sha(path: &Path) -> anyhow::Result<String> {
let output = Command::new("git")
.args(["rev-parse", "--short=8", "HEAD"])
.current_dir(path)
.output()
.map_err(|e| anyhow::anyhow!("Failed to run git rev-parse: {}. Is git installed?", e))?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"git rev-parse failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
async fn handle_branch_switch(
watch_path: &Path,
store: &db::SqliteStore,
repo: &str,
repo_id: i64,
current_branch: &Arc<RwLock<String>>,
worktree_id: &Arc<RwLock<i64>>,
debouncer: &indexer::DebouncedHandler,
) -> anyhow::Result<()> {
use maproom::git::get_current_branch;
use maproom::incremental::incremental_update;
use maproom::indexer::BranchSwitchEvent;
if !debouncer.should_handle() {
tracing::debug!("Debouncing rapid branch switch");
return Ok(());
}
let new_branch = match get_current_branch(watch_path) {
Ok(b) => b,
Err(e) => {
tracing::warn!("Failed to get current branch: {}", e);
return Ok(()); }
};
let effective_branch = if new_branch == "HEAD" {
match get_short_commit_sha(watch_path) {
Ok(sha) => sha,
Err(e) => {
tracing::warn!("Failed to get commit SHA for detached HEAD: {}", e);
return Ok(()); }
}
} else {
new_branch
};
let old_branch = current_branch.read().unwrap().clone();
let old_wt_id = *worktree_id.read().unwrap();
if old_branch == effective_branch {
tracing::debug!("Same branch '{}', skipping", effective_branch);
return Ok(()); }
tracing::info!("Branch switch: '{}' -> '{}'", old_branch, effective_branch);
let watch_path_str = watch_path.to_string_lossy().to_string();
let new_wt_id = match store
.get_or_create_worktree(repo_id, &effective_branch, &watch_path_str)
.await
{
Ok(id) => id,
Err(e) => {
tracing::warn!(
"Failed to get/create worktree: {}. Continuing with old worktree_id.",
e
);
return Ok(()); }
};
let worktree_created = new_wt_id != old_wt_id;
{
*current_branch.write().unwrap() = effective_branch.clone();
*worktree_id.write().unwrap() = new_wt_id;
}
if let Err(e) = incremental_update(store, new_wt_id, watch_path).await {
tracing::warn!("Incremental update after branch switch failed: {}", e);
}
let event = BranchSwitchEvent {
event_type: "branch_switched",
timestamp: chrono::Utc::now().to_rfc3339(),
repo: repo.to_string(),
old_branch,
new_branch: effective_branch,
old_worktree_id: old_wt_id,
new_worktree_id: new_wt_id,
worktree_created,
};
if let Ok(json) = serde_json::to_string(&event) {
println!("{}", json);
}
Ok(())
}
#[derive(Parser, Debug)]
#[command(
name = "maproom",
version,
about = "Maproom indexer & CLI",
after_help = include_str!("../docs/cli-help-after.md")
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Db {
#[command(subcommand)]
command: DbCommand,
},
Cache {
#[command(subcommand)]
command: maproom::cli::CacheCommand,
},
Context {
#[arg(long)]
chunk_id: i64,
#[arg(long, default_value_t = 6000)]
budget: usize,
#[arg(long)]
callers: bool,
#[arg(long)]
callees: bool,
#[arg(long)]
tests: bool,
#[arg(long)]
docs: bool,
#[arg(long)]
config: bool,
#[arg(long, default_value_t = 2)]
max_depth: i32,
#[arg(long, value_enum, default_value_t = OutputFormat::Json)]
format: OutputFormat,
#[arg(long, hide = true)]
json: bool,
},
Scan {
#[arg(long)]
repo: Option<String>,
#[arg(long)]
worktree: Option<String>,
#[arg(long)]
path: Option<PathBuf>,
#[arg(long)]
commit: Option<String>,
#[arg(long, default_value_t = 4)]
concurrency: usize,
#[arg(long, value_delimiter = ',')]
languages: Option<Vec<String>>, #[arg(long, value_delimiter = ',')]
exclude: Option<Vec<String>>, #[arg(long, default_value_t = false)]
force: bool,
#[arg(
long,
default_value_t = true,
action = clap::ArgAction::Set,
help = "Generate embeddings for vector search (default: true)",
long_help = "Generate embeddings for vector search.\n\
Embeddings enable semantic search via vector-search command.\n\
Full-text search works without embeddings.\n\n\
Skip embeddings with --generate-embeddings=false or --no-generate-embeddings when:\n\
- Embedding provider is not configured\n\
- Only using full-text search\n\
- Troubleshooting configuration issues"
)]
generate_embeddings: bool,
#[arg(long, default_value_t = 50)]
embedding_batch_size: usize,
#[arg(long, value_parser = validate_provider)]
provider: Option<String>,
#[arg(long)]
verbose: bool,
#[arg(long)]
json: bool,
},
Upsert {
#[arg(long, value_delimiter = ',')]
paths: Vec<PathBuf>,
#[arg(long)]
commit: String,
#[arg(long)]
repo: String,
#[arg(long)]
worktree: String,
#[arg(long)]
root: PathBuf,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
generate_embeddings: bool,
#[arg(long, default_value_t = 50)]
embedding_batch_size: usize,
#[arg(long, value_parser = validate_provider)]
provider: Option<String>,
},
Watch {
#[arg(long)]
repo: Option<String>,
#[arg(long)]
worktree: Option<String>,
#[arg(long)]
path: Option<PathBuf>,
#[arg(long, default_value = "2s")]
throttle: String,
#[arg(long, default_value_t = false)]
json: bool,
},
Search {
#[arg(long)]
repo: String,
#[arg(long)]
worktree: Option<String>,
#[arg(long)]
query: String,
#[arg(long, default_value_t = 10)]
k: i64,
#[arg(long, default_value_t = false)]
debug: bool,
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
deduplicate: bool,
#[arg(long, value_delimiter = ',')]
kind: Option<Vec<String>>,
#[arg(long, value_delimiter = ',')]
lang: Option<Vec<String>>,
#[arg(long, default_value_t = false)]
preview: bool,
#[arg(long)]
preview_length: Option<usize>,
#[arg(long, value_enum, default_value_t = OutputFormat::Json)]
format: OutputFormat,
},
VectorSearch {
#[arg(long)]
repo: String,
#[arg(long)]
worktree: Option<String>,
#[arg(long)]
query: String,
#[arg(long, default_value_t = 10)]
k: usize,
#[arg(long)]
threshold: Option<f32>,
#[arg(long, value_delimiter = ',')]
kind: Option<Vec<String>>,
#[arg(long, value_delimiter = ',')]
lang: Option<Vec<String>>,
#[arg(long, default_value_t = false)]
preview: bool,
#[arg(long)]
preview_length: Option<usize>,
#[arg(long, value_enum, default_value_t = OutputFormat::Json)]
format: OutputFormat,
},
Status {
#[arg(long)]
repo: Option<String>,
#[arg(long)]
worktree: Option<String>,
#[arg(long, default_value_t = false)]
json: bool,
#[arg(long, default_value_t = false)]
verbose: bool,
},
EncodingProgress {
#[arg(long)]
repo: Option<String>,
#[arg(long, default_value_t = false)]
json: bool,
},
GenerateEmbeddings {
#[arg(long, default_value_t = true)]
incremental: bool,
#[arg(long, default_value_t = 100)]
batch_size: usize,
#[arg(long, default_value_t = false)]
dry_run: bool,
#[arg(long)]
sample: Option<usize>,
#[arg(long, default_value_t = 100)]
batch_delay: u64,
#[arg(long)]
max_cost: Option<f64>,
#[arg(long, default_value_t = false)]
force: bool,
},
Migrate {
#[command(subcommand)]
command: MigrateCommand,
},
Serve {
#[arg(long)]
socket: bool,
#[arg(long)]
socket_path: Option<PathBuf>,
#[arg(long, default_value_t = 300)]
idle_timeout: u64,
},
CleanIgnored {
#[arg(long, required = true)]
repo: String,
#[arg(long, required = true)]
worktree: String,
#[arg(long, default_value_t = false)]
dry_run: bool,
},
}
#[derive(Subcommand, Debug)]
enum MigrateCommand {
Markdown {
#[arg(long)]
repo: String,
#[arg(long)]
worktree: Option<String>,
},
Rollback {
#[arg(long)]
backup: String,
},
ListBackups,
DeleteBackup {
#[arg(long)]
backup: String,
},
Verify {
#[arg(long)]
repo: String,
},
}
#[derive(Subcommand, Debug)]
enum DbCommand {
Migrate,
CleanupStale {
#[arg(long, help = "Actually delete (default is dry-run)")]
confirm: bool,
#[arg(long, short, help = "Show detailed information")]
verbose: bool,
},
}
async fn auto_generate_embeddings(
batch_size: usize,
provider: Option<String>,
json_mode: bool,
) -> anyhow::Result<maproom::embedding::PipelineStats> {
use maproom::embedding::{EmbeddingPipeline, EmbeddingService, PipelineConfig};
tracing::info!("Starting auto-embedding generation");
if !json_mode {
println!("\n🔄 Generating embeddings for new chunks...");
}
if let Some(ref provider_name) = provider {
tracing::info!("Using provider from CLI flag: {}", provider_name);
std::env::set_var("MAPROOM_EMBEDDING_PROVIDER", provider_name);
}
let service = match EmbeddingService::from_env().await {
Ok(s) => {
tracing::info!(
"Created embedding service with provider: {}",
s.provider_name()
);
s
}
Err(e) => {
let provider_name = std::env::var("MAPROOM_EMBEDDING_PROVIDER").unwrap_or_default();
if provider_name.to_lowercase() == "ollama" || provider_name.is_empty() {
tracing::warn!("Embedding service unavailable: {}", e);
return Err(anyhow::anyhow!(
"Embedding service not available. Configure MAPROOM_EMBEDDING_PROVIDER (openai/ollama/google) and API keys in .env file."
));
}
return Err(e.into());
}
};
let config = PipelineConfig {
batch_size,
incremental: true, dry_run: false,
sample_size: None,
batch_delay_ms: 100,
max_cost_usd: None,
};
let store = maproom::db::connect().await?;
let chunk_count = store.get_chunks_needing_embeddings_count().await?;
if chunk_count == 0 {
if !json_mode {
println!(" ✓ All chunks already have embeddings");
}
return Ok(maproom::embedding::PipelineStats::default());
}
if !json_mode {
println!(" Found {} chunks needing embeddings", chunk_count);
}
let output_mode = if json_mode {
maproom::progress::OutputMode::Json
} else {
maproom::progress::OutputMode::Minimal
};
let progress = maproom::progress::ProgressTracker::new(output_mode);
progress.set_totals(0, Some(chunk_count as usize));
let pipeline = EmbeddingPipeline::new(service, config);
let stats = pipeline
.run_with_progress(
&store,
Some(&|processed, _total| {
progress.update_chunks(processed);
if progress.should_print() {
progress.print_progress();
}
}),
)
.await?;
progress.finish();
Ok(stats)
}
fn parse_throttle(throttle: &str) -> anyhow::Result<u64> {
let throttle = throttle.trim();
if let Some(ms_str) = throttle.strip_suffix("ms") {
ms_str
.parse::<u64>()
.with_context(|| format!("Invalid throttle value: {}", throttle))
} else if let Some(s_str) = throttle.strip_suffix("s") {
let secs: u64 = s_str
.parse()
.with_context(|| format!("Invalid throttle value: {}", throttle))?;
Ok(secs * 1000)
} else {
let secs: u64 = throttle.parse().with_context(|| {
format!(
"Invalid throttle value: {}. Use format like '2s' or '500ms'",
throttle
)
})?;
Ok(secs * 1000)
}
}
#[allow(dead_code)] fn role_emoji(role: &str) -> &'static str {
match role.to_lowercase().as_str() {
"primary" => "📄",
"caller" => "🔗",
"callee" => "📤",
"test" => "🧪",
"doc" => "📚",
"config" => "⚙️",
"hook" => "🪝",
"jsx_parent" => "⬆️",
"jsx_child" => "⬇️",
_ => "📎",
}
}
#[allow(dead_code)] fn format_context_bundle(bundle: &ContextBundle, chunk_id: i64, budget: usize) -> String {
let mut output = String::new();
let _ = writeln!(output, "📦 Context Bundle for chunk #{}", chunk_id);
let _ = writeln!(
output,
" Budget: {} tokens | Used: {} tokens | Truncated: {}",
budget,
bundle.total_tokens,
if bundle.truncated { "Yes" } else { "No" }
);
let _ = writeln!(output);
for item in &bundle.items {
let emoji = role_emoji(&item.role);
let role_upper = item.role.to_uppercase();
let _ = writeln!(
output,
"{} {}: {}:{}-{}",
emoji, role_upper, item.relpath, item.range.start, item.range.end
);
if item.role.to_lowercase() == "primary" {
let _ = writeln!(output, " ─────────────────────────────────────────");
for (i, line) in item.content.lines().take(10).enumerate() {
let truncated_line = if line.len() > 80 {
format!("{}...", &line[..77])
} else {
line.to_string()
};
let _ = writeln!(output, " {}", truncated_line);
if i >= 9 {
let _ = writeln!(output, " ... (content truncated)");
break;
}
}
let _ = writeln!(output, " ─────────────────────────────────────────");
} else {
if !item.reason.is_empty() {
let _ = writeln!(output, " Reason: {}", item.reason);
}
}
let _ = writeln!(output, " Tokens: {}", item.tokens);
let _ = writeln!(output);
}
if bundle.items.is_empty() {
let _ = writeln!(output, " (No context items found)");
}
output
}
fn get_git_info(path: &Path) -> anyhow::Result<(String, String, String)> {
let repo_name = Command::new("git")
.args([
"-C",
path.to_str().unwrap_or("."),
"remote",
"get-url",
"origin",
])
.output()
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout).ok()
} else {
None
}
})
.and_then(|url| {
let url = url.trim();
let re = regex::Regex::new(r"[:/]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
re.captures(url).map(|cap| cap[1].to_string())
})
.unwrap_or_else(|| {
path.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "unknown".to_string())
});
let branch_name = Command::new("git")
.args([
"-C",
path.to_str().unwrap_or("."),
"rev-parse",
"--abbrev-ref",
"HEAD",
])
.output()
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "main".to_string());
let commit_hash = Command::new("git")
.args(["-C", path.to_str().unwrap_or("."), "rev-parse", "HEAD"])
.output()
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout)
.ok()
.map(|s| s.trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "HEAD".to_string());
Ok((repo_name, branch_name, commit_hash))
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv().ok();
fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_target(false)
.compact()
.init();
let cli = Cli::parse();
match cli.command {
Commands::Db { command } => match command {
DbCommand::Migrate => {
let _store = db::connect().await?;
println!("✅ SQLite database is up to date");
}
DbCommand::CleanupStale { confirm, verbose } => {
let start_time = std::time::Instant::now();
let store = db::connect().await?;
println!("🔍 Detecting stale worktrees...");
let stale = match store.detect_stale_worktrees().await {
Ok(worktrees) => worktrees,
Err(e) => {
eprintln!("❌ Error detecting stale worktrees: {}", e);
std::process::exit(EXIT_RUNTIME_ERROR);
}
};
if stale.is_empty() {
println!("✅ No stale worktrees found!");
return Ok(()); }
println!("📊 Found {} stale worktree(s):", stale.len());
for wt in &stale {
println!(
" • {} (path: {}, chunks: {})",
wt.name,
wt.abs_path,
format_number(wt.chunk_count)
);
if verbose {
println!(
" ID: {}, Repo ID: {}, Exists: {}",
wt.id, wt.repo_id, wt.exists
);
}
}
let total_chunks: i64 = stale.iter().map(|wt| wt.chunk_count).sum();
println!(
"\n💾 Total chunks to delete: {}",
format_number(total_chunks)
);
if confirm {
println!("🗑️ Deleting {} stale worktree(s)...", stale.len());
let mut deleted_count = 0;
let mut chunks_cleaned = 0i64;
let mut failures = Vec::new();
for wt in &stale {
match store.delete_worktree_data(wt.id).await {
Ok(result) => {
deleted_count += 1;
chunks_cleaned += result.chunks_deleted as i64;
}
Err(e) => {
failures.push((wt.id, e.to_string()));
}
}
}
let elapsed = start_time.elapsed();
println!("✅ Cleanup complete!");
println!(" Deleted: {}/{}", deleted_count, stale.len());
println!(" Chunks cleaned: {}", format_number(chunks_cleaned));
println!(" Time taken: {:.2}s", elapsed.as_secs_f64());
if !failures.is_empty() {
println!(" ⚠️ Failures: {}", failures.len());
if verbose {
for (id, err) in &failures {
println!(" Worktree {}: {}", id, err);
}
}
}
} else {
let elapsed = start_time.elapsed();
println!("⚠️ This was a dry-run. Use --confirm to actually delete.");
println!(" Command: maproom db cleanup-stale --confirm");
println!(" Time taken: {:.2}s", elapsed.as_secs_f64());
}
}
},
Commands::Cache { command } => {
command.execute().await?;
}
Commands::Scan {
repo,
worktree,
path,
commit,
concurrency,
languages,
exclude,
force,
generate_embeddings,
embedding_batch_size,
provider,
verbose,
json,
} => {
let path = path.unwrap_or_else(|| PathBuf::from("."));
let (repo_name, branch_name, commit_hash) = get_git_info(&path)?;
let repo = repo.unwrap_or(repo_name);
let worktree = worktree.unwrap_or(branch_name);
let commit = commit.unwrap_or(commit_hash);
tracing::info!(
"Scanning repo: {}, worktree: {}, commit: {}, force: {}, generate_embeddings: {}",
repo,
worktree,
commit,
force,
generate_embeddings
);
if force {
tracing::info!("🔄 Force flag enabled - performing full repository scan");
if !json {
println!("🔄 Full scan mode (--force flag enabled)");
}
} else {
tracing::info!(
"⚡ Incremental mode - only scanning changed files (use --force for full scan)"
);
if !json {
println!("⚡ Incremental scan mode (use --force for full scan)");
}
}
let mode = if json {
OutputMode::Json
} else if verbose {
OutputMode::Verbose
} else {
OutputMode::Minimal
};
let progress = ProgressTracker::new(mode);
let store = db::connect().await?;
let tree_sha = match maproom::git::get_git_tree_sha(&path) {
Ok(sha) => {
tracing::info!("Current tree SHA: {}", sha);
Some(sha)
}
Err(e) => {
tracing::warn!("Could not get tree SHA: {}, proceeding with full scan", e);
None
}
};
if let Some(ref current_sha) = tree_sha {
let root_abs = path.canonicalize().context("invalid root path")?;
let repo_id = match store
.get_or_create_repo(&repo, root_abs.to_string_lossy().as_ref())
.await
{
Ok(id) => Some(id),
Err(e) => {
tracing::warn!("Could not get repo ID: {}, proceeding with full scan", e);
None
}
};
if let Some(repo_id) = repo_id {
let worktree_id = match store
.get_or_create_worktree(
repo_id,
&worktree,
root_abs.to_string_lossy().as_ref(),
)
.await
{
Ok(id) => Some(id),
Err(e) => {
tracing::warn!(
"Could not get worktree ID: {}, proceeding with full scan",
e
);
None
}
};
if let Some(wt_id) = worktree_id {
match store.get_last_indexed_tree(wt_id).await {
Ok(last_sha) if last_sha == *current_sha && !force => {
if json {
println!(
r#"{{"type":"complete","files":0,"duration":0,"elapsed":0,"timestamp":"{}"}}"#,
chrono::Utc::now().to_rfc3339()
);
} else {
println!(
"✓ No changes detected (tree SHA match), skipping scan"
);
}
tracing::info!(
"Scan skipped: tree {} already indexed",
current_sha
);
return Ok(()); }
Ok(last_sha) if last_sha != "init" => {
tracing::info!("Tree changed: {} -> {}", last_sha, current_sha);
}
Ok(_) => {
tracing::info!("First-time indexing (no cached state)");
}
Err(e) => {
tracing::warn!(
"Could not query index state: {}, proceeding with full scan",
e
);
}
}
}
}
}
indexer::scan_worktree(
&store,
&repo,
&worktree,
&path,
&commit,
concurrency,
languages,
exclude,
Some(&progress),
)
.await
.with_context(|| format!("scan failed for {}@{}", worktree, commit))?;
if generate_embeddings {
match auto_generate_embeddings(embedding_batch_size, provider, json).await {
Ok(stats) => {
if stats.total_chunks > 0 && !json {
println!("\n📊 Embedding Generation Summary:");
println!(" {}", stats.summary());
}
}
Err(e) => {
tracing::warn!("Embedding generation failed: {}", e);
if json {
println!(
r#"{{"type":"error","message":"Embedding generation failed: {}","error_type":"embedding"}}"#,
e
);
} else {
println!("\n⚠️ Warning: Embedding generation failed: {}", e);
println!(" You can generate embeddings later with: maproom generate-embeddings");
}
}
}
}
if let Some(ref current_tree_sha) = tree_sha {
let files_processed = progress.files_processed() as i32;
let chunks_processed = progress.chunks_processed() as i32;
let embeddings_generated = if generate_embeddings {
chunks_processed
} else {
0
};
let scan_stats = maproom::db::UpdateStats {
files_processed,
chunks_processed,
embeddings_generated,
};
let root_abs = match path.canonicalize() {
Ok(p) => p,
Err(e) => {
tracing::warn!("Could not canonicalize path for state update: {}", e);
path.clone()
}
};
let repo_id = match store
.get_or_create_repo(&repo, root_abs.to_string_lossy().as_ref())
.await
{
Ok(id) => Some(id),
Err(e) => {
tracing::warn!("Could not get repo ID for state update: {}", e);
None
}
};
if let Some(repo_id) = repo_id {
let worktree_id = match store
.get_or_create_worktree(
repo_id,
&worktree,
root_abs.to_string_lossy().as_ref(),
)
.await
{
Ok(id) => Some(id),
Err(e) => {
tracing::warn!("Could not get worktree ID for state update: {}", e);
None
}
};
if let Some(wt_id) = worktree_id {
match store
.update_index_state(wt_id, current_tree_sha, &scan_stats)
.await
{
Ok(_) => {
tracing::info!(
"✓ Updated index state: tree {} ({} files, {} chunks, {} embeddings)",
current_tree_sha, files_processed, chunks_processed, embeddings_generated
);
}
Err(e) => {
tracing::warn!("Failed to update index state: {}", e);
tracing::warn!(
"Scan completed successfully, but next scan may be slower"
);
}
}
}
}
}
}
Commands::Upsert {
paths,
commit,
repo,
worktree,
root,
generate_embeddings,
embedding_batch_size,
provider,
} => {
let store = db::connect().await?;
indexer::upsert_files(&store, &repo, &worktree, &root, &commit, &paths)
.await
.with_context(|| "upsert failed")?;
if generate_embeddings {
match auto_generate_embeddings(embedding_batch_size, provider, false).await {
Ok(stats) => {
if stats.total_chunks > 0 {
println!("\n📊 Embedding Generation Summary:");
println!(" {}", stats.summary());
}
}
Err(e) => {
tracing::warn!("Embedding generation failed: {}", e);
println!("\n⚠️ Warning: Embedding generation failed: {}", e);
println!(" You can generate embeddings later with: maproom generate-embeddings");
}
}
}
}
Commands::Watch {
repo,
worktree,
path,
throttle,
json,
} => {
let path = path.unwrap_or_else(|| PathBuf::from("."));
let (repo_name, _, _) = get_git_info(&path)?;
let repo = repo.unwrap_or(repo_name);
let detected_branch = maproom::git::get_current_branch(&path)?;
let worktree = if let Some(_wt) = worktree {
if !json {
eprintln!("Warning: --worktree flag is deprecated and ignored.");
eprintln!("The watch command now auto-detects branch switches.");
eprintln!("Using auto-detected branch: {}", detected_branch);
}
detected_branch
} else {
detected_branch
};
tracing::info!(
repo = %repo,
worktree = %worktree,
path = %path.display(),
throttle = %throttle,
"Starting watch"
);
let store = Arc::new(db::connect().await?);
let watch_path = path
.canonicalize()
.with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
let watch_path_str = watch_path.to_string_lossy().to_string();
let repo_id = store.get_or_create_repo(&repo, &watch_path_str).await?;
let initial_worktree_id = store
.get_or_create_worktree(repo_id, &worktree, &watch_path_str)
.await?;
let worktree_id: Arc<RwLock<i64>> = Arc::new(RwLock::new(initial_worktree_id));
let current_branch: Arc<RwLock<String>> = Arc::new(RwLock::new(worktree.clone()));
use maproom::indexer::DebouncedHandler;
let branch_debouncer = DebouncedHandler::new(std::time::Duration::from_secs(2));
use maproom::incremental::{MultiWatcher, WatcherConfig};
let debounce_ms = parse_throttle(&throttle)?;
let config = WatcherConfig {
debounce_ms,
..Default::default()
};
let (mut multi_watcher, mut event_rx) = MultiWatcher::new(config);
multi_watcher
.add_worktree(worktree.clone(), watch_path.clone())
.await?;
use maproom::indexer::setup_head_watcher;
let git_head = watch_path.join(".git/HEAD");
let (head_tx, mut head_rx) = tokio::sync::mpsc::channel(10);
let _head_watcher = setup_head_watcher(&git_head, head_tx)?;
if !json {
println!("👀 Watching {} for changes...", watch_path.display());
println!(" Repository: {}", repo);
println!(" Worktree: {}", worktree);
println!(" Throttle: {}", throttle);
println!();
println!("Press Ctrl+C to stop.");
}
use maproom::incremental::incremental_update;
use tokio::signal;
loop {
tokio::select! {
_ = signal::ctrl_c() => {
if !json {
println!("\n🛑 Shutting down watch...");
}
multi_watcher.shutdown().await?;
break;
}
Some(event) = event_rx.recv() => {
use maproom::incremental::EventType;
let event_type = match event.event_type {
EventType::Modified => "modified",
EventType::Deleted => "deleted",
EventType::Renamed => "renamed",
};
if json {
println!(
r#"{{"type":"file_event","event":"{}","path":"{}","timestamp":"{}"}}"#,
event_type,
event.path.display(),
chrono::Utc::now().to_rfc3339()
);
} else {
println!("📁 {} {}", event_type, event.path.display());
}
let wt_id = *worktree_id.read().unwrap();
match incremental_update(&store, wt_id, &watch_path).await {
Ok(stats) => {
if stats.files_processed > 0 {
if json {
println!(
r#"{{"type":"update_complete","files_processed":{},"timestamp":"{}"}}"#,
stats.files_processed,
chrono::Utc::now().to_rfc3339()
);
} else {
println!(" ✅ Processed {} files", stats.files_processed);
}
}
}
Err(e) => {
tracing::warn!("Incremental update failed: {}", e);
if json {
println!(
r#"{{"type":"update_error","error":"{}","timestamp":"{}"}}"#,
e,
chrono::Utc::now().to_rfc3339()
);
} else {
println!(" ⚠️ Update failed: {}", e);
}
}
}
}
Some(_head_event) = head_rx.recv() => {
if let Err(e) = handle_branch_switch(
&watch_path,
&store,
&repo,
repo_id,
¤t_branch,
&worktree_id,
&branch_debouncer,
).await {
tracing::warn!("Branch switch handler error: {}", e);
}
}
}
}
if !json {
println!("Watch complete.");
}
}
Commands::Search {
repo,
worktree,
query,
k,
debug,
deduplicate,
kind,
lang,
preview,
preview_length,
format,
} => {
let (preview_enabled, preview_len) = if format == OutputFormat::Agent {
(true, preview_length.unwrap_or(120))
} else {
(preview, preview_length.unwrap_or(200))
};
let store = handle_agent_error!(db::connect().await, format);
let fetch_k = if deduplicate { k * 3 } else { k };
let (hits, total_count) = handle_agent_error!(
store
.search_chunks_fts(
&repo,
worktree.as_deref(),
&query,
fetch_k,
debug,
kind.as_deref(),
lang.as_deref(),
)
.await,
format
);
let hits = if deduplicate {
deduplicate_search_hits(hits, k as usize)
} else {
hits
};
let hits: Vec<_> = hits
.into_iter()
.map(|mut hit| {
if preview_enabled {
if let Some(preview_text) = hit.preview.take() {
hit.preview = Some(db::truncate_preview(&preview_text, preview_len));
}
} else {
hit.preview = None;
}
hit
})
.collect();
let meta = SearchMetadata {
query: query.clone(),
mode: "fts".to_string(),
hits: hits.len(),
total_estimate: total_count,
};
match format {
OutputFormat::Json => {
let output = format_hits_json_search(&hits, &meta)?;
println!("{}", output);
}
OutputFormat::Agent => {
let output = format_hits_agent(&hits, &meta);
println!("{}", output);
}
}
}
Commands::VectorSearch {
repo,
worktree,
query,
k,
threshold,
kind,
lang,
preview,
preview_length,
format,
} => {
use maproom::embedding::EmbeddingService;
let (preview_enabled, preview_len) = if format == OutputFormat::Agent {
(true, preview_length.unwrap_or(120))
} else {
(preview, preview_length.unwrap_or(200))
};
let store = handle_agent_error!(db::connect().await, format);
tracing::info!("Generating embedding for query: {}", query);
let provider = std::env::var("MAPROOM_EMBEDDING_PROVIDER")
.unwrap_or_default()
.trim()
.to_string();
if provider.is_empty() {
eprintln!("Configuration error: MAPROOM_EMBEDDING_PROVIDER not set or empty");
eprintln!("Set MAPROOM_EMBEDDING_PROVIDER to 'openai', 'voyage', or another supported provider");
std::process::exit(EXIT_CONFIG_ERROR);
}
let embedding_service = handle_agent_error!(
EmbeddingService::from_env().await.map_err(
|e| anyhow::Error::from(e).context("Failed to create embedding service")
),
format
);
let query_embedding = handle_agent_error!(
embedding_service.embed_text(&query).await.map_err(
|e| anyhow::Error::from(e).context("Failed to generate query embedding")
),
format
);
tracing::info!(
"Executing vector search (k={}, threshold={:?})",
k,
threshold
);
let search_hits = match store
.search_chunks_vector(
&repo,
worktree.as_deref(),
&query_embedding,
k as i64,
false,
kind.as_deref(),
lang.as_deref(),
)
.await
{
Ok(hits) => hits,
Err(e) => {
let err_str = e.to_string();
if err_str.contains("sqlite-vec")
|| err_str.contains("vector")
|| err_str.contains("not available")
{
let error_msg = format!("Vector search unavailable: {}", e);
let suggestion = "Use search command for full-text search instead";
if format == OutputFormat::Agent {
eprintln!("{}", error_msg);
eprintln!("Tip: {}", suggestion);
println!(
"{}",
format_agent_error("config_error", &error_msg, suggestion)
);
} else {
eprintln!("{}", error_msg);
eprintln!("Tip: {}", suggestion);
}
std::process::exit(EXIT_CONFIG_ERROR);
}
if format == OutputFormat::Agent {
let (error_type, suggestion, exit_code) = classify_error(&e);
handle_agent_error(&e, &format, &error_type, &suggestion, exit_code);
}
return Err(e);
}
};
let hits: Vec<_> = search_hits
.into_iter()
.filter(|hit| {
if let Some(thresh) = threshold {
hit.score >= thresh as f64
} else {
true
}
})
.map(|mut hit| {
if preview_enabled {
if let Some(preview_text) = hit.preview.take() {
hit.preview = Some(db::truncate_preview(&preview_text, preview_len));
}
} else {
hit.preview = None;
}
hit
})
.collect();
match format {
OutputFormat::Json => {
let json_hits: Vec<serde_json::Value> = hits
.iter()
.map(|hit| {
let mut obj = serde_json::json!({
"chunk_id": hit.chunk_id,
"score": hit.score,
"start_line": hit.start_line,
"end_line": hit.end_line,
"symbol_name": hit.symbol_name,
"kind": hit.kind,
"file_relpath": hit.file_relpath,
"file_path": hit.file_relpath,
});
if let Some(ref preview_text) = hit.preview {
obj.as_object_mut()
.unwrap()
.insert("preview".to_string(), serde_json::json!(preview_text));
}
obj
})
.collect();
let output = format_hits_json_vector(
&json_hits,
json_hits.len(),
&query,
"vector",
k,
threshold,
)?;
println!("{}", output);
}
OutputFormat::Agent => {
let meta = SearchMetadata {
query: query.clone(),
mode: "vector".to_string(),
hits: hits.len(),
total_estimate: hits.len(),
};
let output = format_hits_agent(&hits, &meta);
println!("{}", output);
}
}
}
Commands::Status {
repo,
worktree,
json,
verbose,
} => {
use maproom::status;
if worktree.is_some() && repo.is_none() {
anyhow::bail!("--worktree requires --repo to be specified");
}
tracing::debug!("status: connecting to database...");
let store = db::connect().await?;
tracing::debug!("status: connected, querying status...");
let status_data = status::get_status(Arc::new(store), repo, worktree, verbose)
.await
.context("Error querying status")?;
tracing::debug!("status: query complete, formatting output...");
if json {
let output = status::format_json(&status_data)?;
println!("{}", output);
} else {
let output = status::format_text(&status_data, verbose);
print!("{}", output);
}
}
Commands::EncodingProgress { repo, json } => {
use maproom::encoding_progress;
tracing::debug!("encoding-progress: connecting to database...");
let store = db::connect().await?;
tracing::debug!("encoding-progress: connected, querying progress...");
let progress_data = encoding_progress::get_encoding_progress(Arc::new(store), repo)
.await
.context("Error querying encoding progress")?;
tracing::debug!("encoding-progress: query complete, formatting output...");
if json {
let output = encoding_progress::format_json(&progress_data)?;
println!("{}", output);
} else {
let output = encoding_progress::format_text(&progress_data);
print!("{}", output);
}
}
Commands::GenerateEmbeddings {
incremental,
batch_size,
dry_run,
sample,
batch_delay,
max_cost,
force,
} => {
use maproom::embedding::{
CostEstimator, EmbeddingPipeline, EmbeddingService, PipelineConfig,
};
tracing::info!("Initializing embedding generation pipeline");
let provider = std::env::var("MAPROOM_EMBEDDING_PROVIDER")
.unwrap_or_default()
.trim()
.to_string();
if provider.is_empty() {
eprintln!("Configuration error: MAPROOM_EMBEDDING_PROVIDER not set or empty");
eprintln!("Set MAPROOM_EMBEDDING_PROVIDER to 'openai', 'voyage', or another supported provider");
std::process::exit(EXIT_CONFIG_ERROR);
}
let service = match EmbeddingService::from_env().await {
Ok(s) => s,
Err(e) => {
eprintln!("Configuration error: {}. Ensure embedding provider and API keys are configured.", e);
eprintln!("Set MAPROOM_EMBEDDING_PROVIDER and corresponding API key (OPENAI_API_KEY, etc.)");
std::process::exit(EXIT_CONFIG_ERROR);
}
};
let config = PipelineConfig {
batch_size,
incremental: if force { false } else { incremental },
dry_run,
sample_size: sample,
batch_delay_ms: batch_delay,
max_cost_usd: max_cost,
};
tracing::info!(
"Pipeline config: batch_size={}, incremental={}, dry_run={}, sample={:?}",
config.batch_size,
config.incremental,
config.dry_run,
config.sample_size
);
let store = db::connect().await?;
let chunk_count = store.get_chunks_needing_embeddings_count().await?;
tracing::info!("Found {} chunks needing embeddings", chunk_count);
let estimator = CostEstimator::default();
let estimate = estimator.estimate_cost(chunk_count as usize);
println!("\n{}\n", estimate.format());
if estimate.estimated_cost_usd > 10.0 {
tracing::warn!(
"Estimated cost is high: ${:.2}. Consider using --sample or --max-cost to limit spending.",
estimate.estimated_cost_usd
);
}
let pipeline = EmbeddingPipeline::new(service, config);
let stats = pipeline.run(&store).await?;
println!("\n{}\n", "=".repeat(60));
println!("Embedding Generation Complete");
println!("{}\n", "=".repeat(60));
println!("{}", stats.summary());
println!("{}", "=".repeat(60));
}
Commands::Migrate { command } => {
use maproom::migrate::{verify_migration, MarkdownMigrator};
let store = db::connect().await?;
match command {
MigrateCommand::Markdown { repo, worktree } => {
println!("Starting markdown migration for repo: {}", repo);
if let Some(ref wt) = worktree {
println!("Worktree: {}", wt);
}
let migrator = MarkdownMigrator::new(store.clone());
let result = migrator.migrate(&repo, worktree.as_deref()).await?;
println!("\n{}", "=".repeat(60));
println!("Migration Complete");
println!("{}", "=".repeat(60));
println!("Files processed: {}", result.stats.files_processed);
println!("Old chunks: {}", result.stats.total_old_chunks);
println!("New chunks: {}", result.stats.total_new_chunks);
println!("Delta: {:+}", result.stats.delta());
println!("Errors: {}", result.stats.files_with_errors);
println!("Backup table: {}", result.backup_table);
if let Some(duration) = result.stats.duration() {
println!(
"Duration: {:.2}s",
duration.num_milliseconds() as f64 / 1000.0
);
}
println!("{}", "=".repeat(60));
println!(
"\nTo rollback: cargo run --bin maproom -- migrate rollback --backup {}",
result.backup_table
);
}
MigrateCommand::Rollback { backup } => {
println!("Rolling back migration from backup: {}", backup);
let migrator = MarkdownMigrator::new(store.clone());
migrator.rollback(&backup).await?;
println!("Rollback complete");
}
MigrateCommand::ListBackups => {
let migrator = MarkdownMigrator::new(store.clone());
let backups = migrator.list_backups().await?;
if backups.is_empty() {
println!("No backup tables found");
} else {
println!("Available backup tables:");
for backup in backups {
println!(" - {}", backup);
}
}
}
MigrateCommand::DeleteBackup { backup } => {
println!("Deleting backup table: {}", backup);
let migrator = MarkdownMigrator::new(store.clone());
migrator.delete_backup(&backup).await?;
println!("Backup deleted");
}
MigrateCommand::Verify { repo } => {
println!("Verifying migration for repo: {}", repo);
let results = verify_migration(&store, &repo).await?;
println!("\n{}", "=".repeat(60));
println!("Migration Verification Results");
println!("{}", "=".repeat(60));
for (key, value) in results.iter() {
println!("{}: {}", key, value);
}
println!("{}", "=".repeat(60));
}
}
}
Commands::Serve {
socket,
socket_path,
idle_timeout,
} => {
if socket {
use maproom::daemon::server::{
run_with_signal_handling, ServerConfig, SocketServer,
};
let mut config = ServerConfig::default_for_user()?;
if let Some(path) = socket_path {
config.socket_path = path;
}
config.idle_timeout = std::time::Duration::from_secs(idle_timeout);
tracing::info!(
socket_path = %config.socket_path.display(),
idle_timeout_secs = idle_timeout,
"Starting socket server with signal handling"
);
let server = SocketServer::new(config)
.await
.map_err(|e| anyhow::anyhow!("Failed to create socket server: {}", e))?;
run_with_signal_handling(server)
.await
.map_err(|e| anyhow::anyhow!("Socket server error: {}", e))?;
} else {
tracing::info!("Starting stdio daemon");
daemon::run().await?;
}
}
Commands::CleanIgnored {
repo,
worktree,
dry_run,
} => {
use maproom::cli::clean_ignored;
let store = db::connect().await?;
clean_ignored::clean_ignored(&store, &repo, &worktree, dry_run).await?;
}
Commands::Context {
chunk_id,
budget,
callers,
callees,
tests,
docs,
config,
max_depth,
format,
json: _,
} => {
let store = db::connect().await.context("Database connection failed")?;
let assembler = DefaultAssemblyStrategy::new(Arc::new(store));
let options = ExpandOptions {
callers,
callees,
tests,
docs,
config,
max_depth,
..Default::default()
};
let bundle = assembler
.assemble(chunk_id, budget, options)
.await
.with_context(|| format!("Failed to assemble context for chunk {}", chunk_id))?;
match format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&bundle)?);
}
OutputFormat::Agent => {
let output = format_context_agent(&bundle, chunk_id, budget);
if !output.is_empty() {
println!("{}", output);
}
}
}
}
}
Ok(())
}
fn handle_agent_error(
error: &anyhow::Error,
format: &OutputFormat,
error_type: &str,
suggestion: &str,
exit_code: i32,
) -> ! {
let error_message: String = error.to_string().chars().take(100).collect();
tracing::error!(
error_type = error_type,
exit_code = exit_code,
format = ?format,
error_message = %error_message,
"Agent error handled"
);
if matches!(format, OutputFormat::Agent) {
let error_msg = error.to_string();
let structured = format_agent_error(error_type, &error_msg, suggestion);
println!("{}", structured);
}
let error_display = format!("{:?}", error);
let sanitized = sanitize_newlines(&error_display);
eprintln!("Error: {}", sanitized);
let _ = io::stdout().flush();
let _ = io::stderr().flush();
std::process::exit(exit_code)
}
fn classify_error(error: &anyhow::Error) -> (String, String, i32) {
use maproom::embedding::error::{ApiError, EmbeddingError};
use maproom::search::errors::SearchErrorDetails;
use maproom::search::pipeline::PipelineError;
if let Some(pipeline_error) = error.downcast_ref::<PipelineError>() {
let details = SearchErrorDetails::from_pipeline_error(pipeline_error);
let (error_type_str, exit_code) = match details.error_type {
maproom::search::errors::ErrorType::EmbeddingProvider => {
if details.context.contains_key("provider")
&& details.suggestions.iter().any(|s| {
s.contains("API_KEY")
|| s.contains("credentials")
|| s.contains("environment variable")
})
{
("embedding_provider", 2)
} else {
("embedding_provider", 1)
}
}
maproom::search::errors::ErrorType::Database => ("database", 1),
maproom::search::errors::ErrorType::Validation => ("validation", 1),
maproom::search::errors::ErrorType::Timeout => ("timeout", 1),
maproom::search::errors::ErrorType::NotFound => ("not_found", 1),
maproom::search::errors::ErrorType::Unknown => ("unknown", 1),
};
let suggestion = if details.suggestions.is_empty() {
"Check logs for details".to_string()
} else {
details.suggestions.join("; ")
};
tracing::info!(
error_type = error_type_str,
exit_code = exit_code,
classification_method = "downcast:PipelineError",
"Error classified"
);
return (error_type_str.to_string(), suggestion, exit_code);
}
if let Some(embedding_error) = error.downcast_ref::<EmbeddingError>() {
let error_str = embedding_error.to_string();
if let EmbeddingError::Api(api_error) = embedding_error {
if matches!(
api_error,
ApiError::Authentication(_) | ApiError::QuotaExceeded(_)
) {
tracing::info!(
error_type = "config_error",
exit_code = 2,
classification_method = "downcast:EmbeddingError",
"Error classified"
);
return (
"config_error".to_string(),
"Invalid or expired API key. Check your API key configuration.".to_string(),
2,
);
}
let api_error_lower = api_error.to_string().to_lowercase();
if api_error_lower.contains("401")
|| api_error_lower.contains("403")
|| api_error_lower.contains("unauthorized")
|| api_error_lower.contains("forbidden")
|| api_error_lower.contains("authentication")
{
tracing::info!(
error_type = "config_error",
exit_code = 2,
classification_method = "downcast:EmbeddingError",
"Error classified"
);
return (
"config_error".to_string(),
"Invalid or expired API key. Check your API key configuration.".to_string(),
2,
);
}
}
if matches!(
embedding_error,
EmbeddingError::Config(_) | EmbeddingError::InvalidInput(_)
) {
tracing::info!(
error_type = "embedding_provider",
exit_code = 2,
classification_method = "downcast:EmbeddingError",
"Error classified"
);
return (
"embedding_provider".to_string(),
"Check your embedding provider configuration".to_string(),
2,
);
}
tracing::info!(
error_type = "embedding_provider",
exit_code = 1,
classification_method = "downcast:EmbeddingError",
"Error classified"
);
return (
"embedding_provider".to_string(),
format!("Embedding provider error: {}", error_str),
1,
);
}
let error_str = error.to_string();
let error_lower = error_str.to_lowercase();
let error_preview: String = error_str.chars().take(100).collect();
tracing::warn!(
error_preview = %error_preview,
classification_method = "heuristic",
"Error classification using heuristic fallback (downcast failed)"
);
if error_lower.contains("config")
|| error_lower.contains("api_key")
|| error_lower.contains("provider")
|| (error_lower.contains("sqlite-vec") && error_lower.contains("not available"))
|| (error_lower.contains("vector") && error_lower.contains("not available"))
{
tracing::info!(
error_type = "config_error",
exit_code = 2,
classification_method = "heuristic",
"Error classified"
);
return (
"config_error".to_string(),
"Check your configuration and environment variables".to_string(),
2,
);
}
if (error_lower.contains("database") || error_lower.contains("connection"))
&& (error_lower.contains("no such file") || error_lower.contains("not found"))
{
tracing::info!(
error_type = "config_error",
exit_code = 2,
classification_method = "heuristic",
"Error classified"
);
return (
"config_error".to_string(),
"Repository not indexed. Run scan command first.".to_string(),
2,
);
}
if error_lower.contains("database") || error_lower.contains("connection") {
tracing::info!(
error_type = "database",
exit_code = 1,
classification_method = "heuristic",
"Error classified"
);
return (
"database".to_string(),
"Check database connectivity and permissions".to_string(),
1,
);
}
if error_lower.contains("chunk") && error_lower.contains("not found") {
tracing::info!(
error_type = "not_found",
exit_code = 1,
classification_method = "heuristic",
"Error classified"
);
return (
"not_found".to_string(),
"The requested chunk may not be indexed".to_string(),
1,
);
}
tracing::info!(
error_type = "unknown",
exit_code = 1,
classification_method = "heuristic",
"Error classified"
);
(
"unknown".to_string(),
"Please report this error with full details".to_string(),
1,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cleanup_stale_defaults() {
let cli = Cli::parse_from(&["maproom", "db", "cleanup-stale"]);
if let Commands::Db {
command: DbCommand::CleanupStale { confirm, verbose },
} = cli.command
{
assert_eq!(confirm, false, "confirm should default to false");
assert_eq!(verbose, false, "verbose should default to false");
} else {
panic!("Expected cleanup-stale command");
}
}
#[test]
fn test_cleanup_stale_with_confirm() {
let cli = Cli::parse_from(&["maproom", "db", "cleanup-stale", "--confirm"]);
if let Commands::Db {
command: DbCommand::CleanupStale { confirm, verbose },
} = cli.command
{
assert_eq!(confirm, true);
assert_eq!(verbose, false);
} else {
panic!("Expected cleanup-stale command");
}
}
#[test]
fn test_cleanup_stale_with_verbose() {
let cli = Cli::parse_from(&["maproom", "db", "cleanup-stale", "--verbose"]);
if let Commands::Db {
command: DbCommand::CleanupStale { confirm, verbose },
} = cli.command
{
assert_eq!(confirm, false);
assert_eq!(verbose, true);
} else {
panic!("Expected cleanup-stale command");
}
}
#[test]
fn test_cleanup_stale_short_verbose() {
let cli = Cli::parse_from(&["maproom", "db", "cleanup-stale", "-v"]);
if let Commands::Db {
command:
DbCommand::CleanupStale {
confirm: _,
verbose,
},
} = cli.command
{
assert_eq!(verbose, true);
} else {
panic!("Expected cleanup-stale command");
}
}
#[test]
fn test_context_command_parsing_minimal() {
let cli = Cli::parse_from(&["maproom", "context", "--chunk-id", "12345"]);
if let Commands::Context {
chunk_id,
budget,
callers,
callees,
tests,
docs,
config,
max_depth,
format,
json,
} = cli.command
{
assert_eq!(chunk_id, 12345);
assert_eq!(budget, 6000); assert_eq!(callers, false);
assert_eq!(callees, false);
assert_eq!(tests, false);
assert_eq!(docs, false);
assert_eq!(config, false);
assert_eq!(max_depth, 2); assert_eq!(format, OutputFormat::Json); assert_eq!(json, false);
} else {
panic!("Expected Context command");
}
}
#[test]
fn test_context_command_parsing_with_expands() {
let cli = Cli::parse_from(&[
"maproom",
"context",
"--chunk-id",
"99999",
"--budget",
"4000",
"--callers",
"--callees",
"--tests",
"--max-depth",
"5",
]);
if let Commands::Context {
chunk_id,
budget,
callers,
callees,
tests,
docs,
config,
max_depth,
format,
json,
} = cli.command
{
assert_eq!(chunk_id, 99999);
assert_eq!(budget, 4000);
assert_eq!(callers, true);
assert_eq!(callees, true);
assert_eq!(tests, true);
assert_eq!(docs, false); assert_eq!(config, false); assert_eq!(max_depth, 5);
assert_eq!(format, OutputFormat::Json); assert_eq!(json, false);
} else {
panic!("Expected Context command");
}
}
#[test]
fn test_context_command_parsing_all_flags() {
let cli = Cli::parse_from(&[
"maproom",
"context",
"--chunk-id",
"42",
"--budget",
"8000",
"--callers",
"--callees",
"--tests",
"--docs",
"--config",
"--max-depth",
"3",
"--json",
]);
if let Commands::Context {
chunk_id,
budget,
callers,
callees,
tests,
docs,
config,
max_depth,
format,
json,
} = cli.command
{
assert_eq!(chunk_id, 42);
assert_eq!(budget, 8000);
assert_eq!(callers, true);
assert_eq!(callees, true);
assert_eq!(tests, true);
assert_eq!(docs, true);
assert_eq!(config, true);
assert_eq!(max_depth, 3);
assert_eq!(format, OutputFormat::Json); assert_eq!(json, true); } else {
panic!("Expected Context command");
}
}
#[test]
fn test_context_format_agent() {
let cli = Cli::parse_from(&[
"maproom",
"context",
"--chunk-id",
"100",
"--format",
"agent",
]);
match cli.command {
Commands::Context { format, .. } => {
assert_eq!(format, OutputFormat::Agent);
}
_ => panic!("Expected Context command"),
}
}
#[test]
fn test_context_format_json() {
let cli = Cli::parse_from(&[
"maproom",
"context",
"--chunk-id",
"100",
"--format",
"json",
]);
match cli.command {
Commands::Context { format, .. } => {
assert_eq!(format, OutputFormat::Json);
}
_ => panic!("Expected Context command"),
}
}
#[test]
fn test_context_format_default_is_json() {
let cli = Cli::parse_from(&["maproom", "context", "--chunk-id", "100"]);
match cli.command {
Commands::Context { format, .. } => {
assert_eq!(format, OutputFormat::Json);
}
_ => panic!("Expected Context command"),
}
}
#[test]
fn test_context_json_flag_backward_compat() {
let cli = Cli::parse_from(&["maproom", "context", "--chunk-id", "100", "--json"]);
match cli.command {
Commands::Context { json, format, .. } => {
assert_eq!(json, true);
assert_eq!(format, OutputFormat::Json); }
_ => panic!("Expected Context command"),
}
}
#[test]
fn test_role_emoji_known_roles() {
assert_eq!(super::role_emoji("primary"), "📄");
assert_eq!(super::role_emoji("caller"), "🔗");
assert_eq!(super::role_emoji("callee"), "📤");
assert_eq!(super::role_emoji("test"), "🧪");
assert_eq!(super::role_emoji("doc"), "📚");
assert_eq!(super::role_emoji("config"), "⚙️");
assert_eq!(super::role_emoji("hook"), "🪝");
assert_eq!(super::role_emoji("jsx_parent"), "⬆️");
assert_eq!(super::role_emoji("jsx_child"), "⬇️");
}
#[test]
fn test_role_emoji_unknown_role() {
assert_eq!(super::role_emoji("unknown"), "📎");
assert_eq!(super::role_emoji("foobar"), "📎");
}
#[test]
fn test_role_emoji_case_insensitive() {
assert_eq!(super::role_emoji("PRIMARY"), "📄");
assert_eq!(super::role_emoji("Caller"), "🔗");
assert_eq!(super::role_emoji("TEST"), "🧪");
}
#[test]
fn test_format_context_bundle_basic() {
use maproom::context::{ContextBundle, ContextItem, LineRange};
let mut bundle = ContextBundle::new();
bundle.add_item(ContextItem {
relpath: "src/auth.ts".to_string(),
range: LineRange::new(10, 30),
role: "primary".to_string(),
reason: "".to_string(),
content: "async function authenticate(user: User) {\n return token;\n}".to_string(),
tokens: 150,
});
let output = super::format_context_bundle(&bundle, 12345, 6000);
assert!(output.contains("📦 Context Bundle for chunk #12345"));
assert!(output.contains("Budget: 6000 tokens"));
assert!(output.contains("Used: 150 tokens"));
assert!(output.contains("Truncated: No"));
assert!(output.contains("📄 PRIMARY: src/auth.ts:10-30"));
assert!(output.contains("Tokens: 150"));
assert!(output.contains("authenticate")); }
#[test]
fn test_format_context_bundle_empty() {
use maproom::context::ContextBundle;
let bundle = ContextBundle::new();
let output = super::format_context_bundle(&bundle, 99999, 6000);
assert!(output.contains("📦 Context Bundle for chunk #99999"));
assert!(output.contains("Used: 0 tokens"));
assert!(output.contains("(No context items found)"));
}
#[test]
fn test_format_context_bundle_truncated() {
use maproom::context::{ContextBundle, ContextItem, LineRange};
let mut bundle = ContextBundle::new();
bundle.truncated = true;
bundle.add_item(ContextItem {
relpath: "src/main.rs".to_string(),
range: LineRange::new(1, 10),
role: "primary".to_string(),
reason: "".to_string(),
content: "fn main() {}".to_string(),
tokens: 5500,
});
let output = super::format_context_bundle(&bundle, 42, 6000);
assert!(output.contains("Truncated: Yes"));
}
#[test]
fn test_format_context_bundle_with_related_items() {
use maproom::context::{ContextBundle, ContextItem, LineRange};
let mut bundle = ContextBundle::new();
bundle.add_item(ContextItem {
relpath: "src/auth.ts".to_string(),
range: LineRange::new(10, 30),
role: "primary".to_string(),
reason: "".to_string(),
content: "function authenticate() {}".to_string(),
tokens: 100,
});
bundle.add_item(ContextItem {
relpath: "src/login.ts".to_string(),
range: LineRange::new(40, 60),
role: "caller".to_string(),
reason: "Calls authenticate function".to_string(),
content: "function login() { authenticate(); }".to_string(),
tokens: 120,
});
bundle.add_item(ContextItem {
relpath: "src/__tests__/auth.test.ts".to_string(),
range: LineRange::new(5, 25),
role: "test".to_string(),
reason: "Test file for primary function".to_string(),
content: "test('auth', () => {});".to_string(),
tokens: 80,
});
let output = super::format_context_bundle(&bundle, 12345, 6000);
assert!(output.contains("📄 PRIMARY: src/auth.ts:10-30"));
assert!(output.contains("🔗 CALLER: src/login.ts:40-60"));
assert!(output.contains("🧪 TEST: src/__tests__/auth.test.ts:5-25"));
assert!(output.contains("Reason: Calls authenticate function"));
assert!(output.contains("Reason: Test file for primary function"));
assert!(output.contains("Used: 300 tokens")); }
#[test]
fn test_search_with_kind_single_value() {
let cli = Cli::parse_from(&[
"maproom", "search", "--repo", "test", "--query", "foo", "--kind", "func",
]);
match cli.command {
Commands::Search { kind, .. } => {
assert_eq!(kind, Some(vec!["func".to_string()]));
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_with_kind_multiple_values() {
let cli = Cli::parse_from(&[
"maproom",
"search",
"--repo",
"test",
"--query",
"foo",
"--kind",
"func,class,method",
]);
match cli.command {
Commands::Search { kind, .. } => {
assert_eq!(
kind,
Some(vec![
"func".to_string(),
"class".to_string(),
"method".to_string(),
])
);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_with_lang_single_value() {
let cli = Cli::parse_from(&[
"maproom", "search", "--repo", "test", "--query", "foo", "--lang", "py",
]);
match cli.command {
Commands::Search { lang, .. } => {
assert_eq!(lang, Some(vec!["py".to_string()]));
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_with_lang_multiple_values() {
let cli = Cli::parse_from(&[
"maproom", "search", "--repo", "test", "--query", "foo", "--lang", "py,ts,rs",
]);
match cli.command {
Commands::Search { lang, .. } => {
assert_eq!(
lang,
Some(vec!["py".to_string(), "ts".to_string(), "rs".to_string(),])
);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_with_both_kind_and_lang() {
let cli = Cli::parse_from(&[
"maproom",
"search",
"--repo",
"test",
"--query",
"foo",
"--kind",
"func,class",
"--lang",
"py,rs",
]);
match cli.command {
Commands::Search { kind, lang, .. } => {
assert_eq!(kind, Some(vec!["func".to_string(), "class".to_string()]));
assert_eq!(lang, Some(vec!["py".to_string(), "rs".to_string()]));
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_with_no_filters() {
let cli = Cli::parse_from(&["maproom", "search", "--repo", "test", "--query", "foo"]);
match cli.command {
Commands::Search { kind, lang, .. } => {
assert_eq!(kind, None);
assert_eq!(lang, None);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_vector_search_with_kind_and_lang() {
let cli = Cli::parse_from(&[
"maproom",
"vector-search",
"--repo",
"test",
"--query",
"authentication logic",
"--kind",
"func,method",
"--lang",
"ts,rs",
]);
match cli.command {
Commands::VectorSearch { kind, lang, .. } => {
assert_eq!(kind, Some(vec!["func".to_string(), "method".to_string()]));
assert_eq!(lang, Some(vec!["ts".to_string(), "rs".to_string()]));
}
_ => panic!("Expected VectorSearch command"),
}
}
#[test]
fn test_vector_search_with_no_filters() {
let cli = Cli::parse_from(&[
"maproom",
"vector-search",
"--repo",
"test",
"--query",
"authentication logic",
]);
match cli.command {
Commands::VectorSearch { kind, lang, .. } => {
assert_eq!(kind, None);
assert_eq!(lang, None);
}
_ => panic!("Expected VectorSearch command"),
}
}
#[test]
fn test_status_default_no_verbose() {
let cli = Cli::parse_from(&["maproom", "status"]);
match cli.command {
Commands::Status {
repo,
worktree,
json,
verbose,
} => {
assert_eq!(repo, None);
assert_eq!(worktree, None);
assert!(!json);
assert!(!verbose);
}
_ => panic!("Expected Status command"),
}
}
#[test]
fn test_status_with_verbose() {
let cli = Cli::parse_from(&["maproom", "status", "--verbose"]);
match cli.command {
Commands::Status { verbose, .. } => {
assert!(verbose);
}
_ => panic!("Expected Status command"),
}
}
#[test]
fn test_status_verbose_with_json() {
let cli = Cli::parse_from(&["maproom", "status", "--verbose", "--json"]);
match cli.command {
Commands::Status { json, verbose, .. } => {
assert!(json);
assert!(verbose);
}
_ => panic!("Expected Status command"),
}
}
#[test]
fn test_status_verbose_with_repo() {
let cli = Cli::parse_from(&["maproom", "status", "--verbose", "--repo", "myrepo"]);
match cli.command {
Commands::Status { repo, verbose, .. } => {
assert_eq!(repo, Some("myrepo".to_string()));
assert!(verbose);
}
_ => panic!("Expected Status command"),
}
}
#[test]
fn test_search_preview_flag() {
let cli = Cli::parse_from(&[
"maproom",
"search",
"--repo",
"test",
"--query",
"foo",
"--preview",
]);
match cli.command {
Commands::Search { preview, .. } => {
assert!(preview);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_preview_length_flag() {
let cli = Cli::parse_from(&[
"maproom",
"search",
"--repo",
"test",
"--query",
"foo",
"--preview-length",
"150",
]);
match cli.command {
Commands::Search { preview_length, .. } => {
assert_eq!(preview_length, Some(150));
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_defaults() {
let cli = Cli::parse_from(&["maproom", "search", "--repo", "test", "--query", "foo"]);
match cli.command {
Commands::Search {
preview,
preview_length,
..
} => {
assert_eq!(preview, false);
assert_eq!(preview_length, None);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_vector_search_preview_flag() {
let cli = Cli::parse_from(&[
"maproom",
"vector-search",
"--repo",
"test",
"--query",
"foo",
"--preview",
]);
match cli.command {
Commands::VectorSearch { preview, .. } => {
assert!(preview);
}
_ => panic!("Expected VectorSearch command"),
}
}
#[test]
fn test_vector_search_preview_length_flag() {
let cli = Cli::parse_from(&[
"maproom",
"vector-search",
"--repo",
"test",
"--query",
"foo",
"--preview-length",
"150",
]);
match cli.command {
Commands::VectorSearch { preview_length, .. } => {
assert_eq!(preview_length, Some(150));
}
_ => panic!("Expected VectorSearch command"),
}
}
#[test]
fn test_search_combined_flags_with_preview() {
let cli = Cli::parse_from(&[
"maproom",
"search",
"--repo",
"test",
"--query",
"foo",
"--preview",
"--kind",
"func",
"--lang",
"py",
]);
match cli.command {
Commands::Search {
preview,
kind,
lang,
..
} => {
assert!(preview);
assert_eq!(kind, Some(vec!["func".to_string()]));
assert_eq!(lang, Some(vec!["py".to_string()]));
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_preview_length_without_preview() {
let cli = Cli::parse_from(&[
"maproom",
"search",
"--repo",
"test",
"--query",
"foo",
"--preview-length",
"150",
]);
match cli.command {
Commands::Search {
preview,
preview_length,
..
} => {
assert_eq!(preview, false);
assert_eq!(preview_length, Some(150));
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_encoding_progress_no_args() {
let cli = Cli::parse_from(&["maproom", "encoding-progress"]);
match cli.command {
Commands::EncodingProgress { repo, json } => {
assert_eq!(repo, None);
assert!(!json);
}
_ => panic!("Expected EncodingProgress command"),
}
}
#[test]
fn test_encoding_progress_json() {
let cli = Cli::parse_from(&["maproom", "encoding-progress", "--json"]);
match cli.command {
Commands::EncodingProgress { repo, json } => {
assert_eq!(repo, None);
assert!(json);
}
_ => panic!("Expected EncodingProgress command"),
}
}
#[test]
fn test_encoding_progress_repo() {
let cli = Cli::parse_from(&["maproom", "encoding-progress", "--repo", "myrepo"]);
match cli.command {
Commands::EncodingProgress { repo, json } => {
assert_eq!(repo, Some("myrepo".to_string()));
assert!(!json);
}
_ => panic!("Expected EncodingProgress command"),
}
}
#[test]
fn test_encoding_progress_repo_and_json() {
let cli = Cli::parse_from(&["maproom", "encoding-progress", "--repo", "myrepo", "--json"]);
match cli.command {
Commands::EncodingProgress { repo, json } => {
assert_eq!(repo, Some("myrepo".to_string()));
assert!(json);
}
_ => panic!("Expected EncodingProgress command"),
}
}
#[test]
fn test_search_format_agent() {
let cli = Cli::parse_from(&[
"maproom", "search", "--repo", "test", "--query", "foo", "--format", "agent",
]);
match cli.command {
Commands::Search { format, .. } => {
assert_eq!(format, OutputFormat::Agent);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_format_json() {
let cli = Cli::parse_from(&[
"maproom", "search", "--repo", "test", "--query", "foo", "--format", "json",
]);
match cli.command {
Commands::Search { format, .. } => {
assert_eq!(format, OutputFormat::Json);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_format_default_is_json() {
let cli = Cli::parse_from(&["maproom", "search", "--repo", "test", "--query", "foo"]);
match cli.command {
Commands::Search { format, .. } => {
assert_eq!(format, OutputFormat::Json);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_format_invalid_produces_error() {
let result = Cli::try_parse_from(&[
"maproom", "search", "--repo", "test", "--query", "foo", "--format", "invalid",
]);
assert!(
result.is_err(),
"Expected clap error for invalid format value"
);
}
#[test]
fn test_vector_search_format_agent() {
let cli = Cli::parse_from(&[
"maproom",
"vector-search",
"--repo",
"test",
"--query",
"auth logic",
"--format",
"agent",
]);
match cli.command {
Commands::VectorSearch { format, .. } => {
assert_eq!(format, OutputFormat::Agent);
}
_ => panic!("Expected VectorSearch command"),
}
}
#[test]
fn test_vector_search_format_default_is_json() {
let cli = Cli::parse_from(&[
"maproom",
"vector-search",
"--repo",
"test",
"--query",
"auth logic",
]);
match cli.command {
Commands::VectorSearch { format, .. } => {
assert_eq!(format, OutputFormat::Json);
}
_ => panic!("Expected VectorSearch command"),
}
}
#[test]
fn test_search_format_agent_with_preview() {
let cli = Cli::parse_from(&[
"maproom",
"search",
"--repo",
"test",
"--query",
"foo",
"--format",
"agent",
"--preview",
]);
match cli.command {
Commands::Search {
format, preview, ..
} => {
assert_eq!(format, OutputFormat::Agent);
assert!(preview);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_format_agent_with_preview_length() {
let cli = Cli::parse_from(&[
"maproom",
"search",
"--repo",
"test",
"--query",
"foo",
"--format",
"agent",
"--preview-length",
"50",
]);
match cli.command {
Commands::Search {
format,
preview_length,
..
} => {
assert_eq!(format, OutputFormat::Agent);
assert_eq!(preview_length, Some(50));
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_format_json_with_preview() {
let cli = Cli::parse_from(&[
"maproom",
"search",
"--repo",
"test",
"--query",
"foo",
"--format",
"json",
"--preview",
]);
match cli.command {
Commands::Search {
format, preview, ..
} => {
assert_eq!(format, OutputFormat::Json);
assert!(preview);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_format_agent_with_kind() {
let cli = Cli::parse_from(&[
"maproom", "search", "--repo", "test", "--query", "foo", "--format", "agent", "--kind",
"func",
]);
match cli.command {
Commands::Search { format, kind, .. } => {
assert_eq!(format, OutputFormat::Agent);
assert_eq!(kind, Some(vec!["func".to_string()]));
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_search_format_agent_with_lang() {
let cli = Cli::parse_from(&[
"maproom", "search", "--repo", "test", "--query", "foo", "--format", "agent", "--lang",
"py",
]);
match cli.command {
Commands::Search { format, lang, .. } => {
assert_eq!(format, OutputFormat::Agent);
assert_eq!(lang, Some(vec!["py".to_string()]));
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_agent_format_implicit_preview_enabled() {
let format = OutputFormat::Agent;
let preview = false; let preview_length: Option<usize> = None;
let (preview_enabled, preview_len) = if format == OutputFormat::Agent {
(true, preview_length.unwrap_or(120))
} else {
(preview, preview_length.unwrap_or(200))
};
assert!(
preview_enabled,
"Agent format must implicitly enable preview"
);
assert_eq!(
preview_len, 120,
"Agent format default preview length must be 120"
);
}
#[test]
fn test_agent_format_explicit_preview_length_override() {
let format = OutputFormat::Agent;
let preview = false;
let preview_length: Option<usize> = Some(50);
let (preview_enabled, preview_len) = if format == OutputFormat::Agent {
(true, preview_length.unwrap_or(120))
} else {
(preview, preview_length.unwrap_or(200))
};
assert!(
preview_enabled,
"Agent format must implicitly enable preview"
);
assert_eq!(
preview_len, 50,
"Explicit preview-length must override agent default"
);
}
#[test]
fn test_json_format_no_implicit_preview() {
let format = OutputFormat::Json;
let preview = false;
let preview_length: Option<usize> = None;
let (preview_enabled, preview_len) = if format == OutputFormat::Agent {
(true, preview_length.unwrap_or(120))
} else {
(preview, preview_length.unwrap_or(200))
};
assert!(
!preview_enabled,
"JSON format must NOT implicitly enable preview"
);
assert_eq!(
preview_len, 200,
"JSON format default preview length must be 200"
);
}
#[test]
fn test_json_format_with_explicit_preview() {
let format = OutputFormat::Json;
let preview = true;
let preview_length: Option<usize> = None;
let (preview_enabled, preview_len) = if format == OutputFormat::Agent {
(true, preview_length.unwrap_or(120))
} else {
(preview, preview_length.unwrap_or(200))
};
assert!(
preview_enabled,
"JSON format with --preview must enable preview"
);
assert_eq!(
preview_len, 200,
"JSON format default preview length must be 200"
);
}
#[test]
fn test_json_format_explicit_preview_length_override() {
let format = OutputFormat::Json;
let preview = true;
let preview_length: Option<usize> = Some(300);
let (preview_enabled, preview_len) = if format == OutputFormat::Agent {
(true, preview_length.unwrap_or(120))
} else {
(preview, preview_length.unwrap_or(200))
};
assert!(preview_enabled);
assert_eq!(
preview_len, 300,
"Explicit preview-length must override JSON default"
);
}
#[test]
fn test_classify_embedding_config_error() {
use maproom::embedding::error::{ConfigError, EmbeddingError};
let config_error = ConfigError::MissingConfig("OPENAI_API_KEY".to_string());
let embedding_error = EmbeddingError::Config(config_error);
let error: anyhow::Error = embedding_error.into();
let (error_type, _suggestion, exit_code) = classify_error(&error);
assert_eq!(error_type, "embedding_provider");
assert_eq!(exit_code, 2, "Config errors should exit with code 2");
}
#[test]
fn test_classify_embedding_api_error() {
use maproom::embedding::error::{ApiError, EmbeddingError};
let api_error = ApiError::RateLimit {
retry_after_ms: 5000,
};
let embedding_error = EmbeddingError::Api(api_error);
let error: anyhow::Error = embedding_error.into();
let (error_type, _suggestion, exit_code) = classify_error(&error);
assert_eq!(error_type, "embedding_provider");
assert_eq!(exit_code, 1, "Runtime API errors should exit with code 1");
}
#[test]
fn test_classify_database_error() {
let error = anyhow::anyhow!("Database connection failed");
let (error_type, suggestion, exit_code) = classify_error(&error);
assert_eq!(error_type, "database");
assert!(suggestion.contains("database") || suggestion.contains("connectivity"));
assert_eq!(exit_code, 1);
}
#[test]
fn test_classify_database_file_not_found() {
let error = anyhow::anyhow!(
"SQLite error: unable to open database file: No such file or directory"
);
let (error_type, suggestion, exit_code) = classify_error(&error);
assert_eq!(exit_code, 2, "Missing database file is a config error");
assert_eq!(error_type, "config_error");
assert!(
suggestion.contains("scan"),
"Suggestion should mention running scan command"
);
}
#[test]
fn test_classify_database_runtime_error_unchanged() {
let error = anyhow::anyhow!("Database query failed: syntax error");
let (error_type, suggestion, exit_code) = classify_error(&error);
assert_eq!(
exit_code, 1,
"Database runtime errors should remain exit code 1"
);
assert_eq!(error_type, "database");
assert!(
suggestion.contains("database") || suggestion.contains("connectivity"),
"Suggestion should be about database connectivity"
);
}
#[test]
fn test_classify_sqlite_vec_error() {
let error = anyhow::anyhow!("sqlite-vec extension not available");
let (error_type, suggestion, exit_code) = classify_error(&error);
assert_eq!(error_type, "config_error");
assert!(suggestion.contains("configuration"));
assert_eq!(exit_code, 2, "Missing extension is a config error");
}
#[test]
fn test_classify_unknown_error() {
let error = anyhow::anyhow!("Something unexpected happened");
let (error_type, suggestion, exit_code) = classify_error(&error);
assert_eq!(error_type, "unknown");
assert!(suggestion.contains("report") || suggestion.contains("details"));
assert_eq!(exit_code, 1);
}
#[test]
fn test_classify_error_with_anyhow_context() {
use maproom::embedding::error::{ConfigError, EmbeddingError};
let config_error = ConfigError::EnvVarNotFound("GOOGLE_API_KEY".to_string());
let embedding_error = EmbeddingError::Config(config_error);
let error: anyhow::Error = embedding_error.into();
let wrapped = error.context("Failed to generate embeddings");
let (error_type, _suggestion, exit_code) = classify_error(&wrapped);
assert_eq!(error_type, "embedding_provider");
assert_eq!(exit_code, 2, "Config errors should exit with code 2");
}
#[test]
fn test_classify_heuristic_fallback() {
let error = anyhow::anyhow!("Mysterious error from unknown source");
let (error_type, _suggestion, exit_code) = classify_error(&error);
assert_eq!(
error_type, "unknown",
"Unrecognized errors should fall back to unknown type"
);
assert_eq!(exit_code, 1);
}
#[test]
fn test_classify_auth_error_exit_code_2() {
use maproom::embedding::error::{ApiError, EmbeddingError};
let auth_error = ApiError::Authentication("Invalid API key".to_string());
let embedding_error = EmbeddingError::Api(auth_error);
let error: anyhow::Error = embedding_error.into();
let (error_type, suggestion, exit_code) = classify_error(&error);
assert_eq!(
exit_code, 2,
"Auth errors should be config errors (exit code 2)"
);
assert_eq!(error_type, "config_error");
assert!(
suggestion.contains("API key"),
"Suggestion should mention API key, got: {}",
suggestion
);
}
#[test]
fn test_classify_quota_exceeded_exit_code_2() {
use maproom::embedding::error::{ApiError, EmbeddingError};
let quota_error = ApiError::QuotaExceeded("Rate limit exceeded for account".to_string());
let embedding_error = EmbeddingError::Api(quota_error);
let error: anyhow::Error = embedding_error.into();
let (error_type, suggestion, exit_code) = classify_error(&error);
assert_eq!(
exit_code, 2,
"Quota exceeded errors should be config errors (exit code 2)"
);
assert_eq!(error_type, "config_error");
assert!(
suggestion.contains("API key"),
"Suggestion should mention API key, got: {}",
suggestion
);
}
#[test]
fn test_classify_forbidden_api_error_heuristic() {
use maproom::embedding::error::{ApiError, EmbeddingError};
let forbidden_error = ApiError::ServerError {
status: 403,
message: "Forbidden: insufficient permissions".to_string(),
};
let embedding_error = EmbeddingError::Api(forbidden_error);
let error: anyhow::Error = embedding_error.into();
let (error_type, _suggestion, exit_code) = classify_error(&error);
assert_eq!(
exit_code, 2,
"403 Forbidden errors should be config errors (exit code 2)"
);
assert_eq!(error_type, "config_error");
}
#[test]
fn test_classify_non_auth_api_error_exit_code_1() {
use maproom::embedding::error::{ApiError, EmbeddingError};
let rate_limit_error = ApiError::RateLimit {
retry_after_ms: 5000,
};
let embedding_error = EmbeddingError::Api(rate_limit_error);
let error: anyhow::Error = embedding_error.into();
let (error_type, _suggestion, exit_code) = classify_error(&error);
assert_eq!(
exit_code, 1,
"Rate limit errors should be runtime errors (exit code 1)"
);
assert_eq!(error_type, "embedding_provider");
}
#[test]
fn test_classify_auth_error_with_context() {
use maproom::embedding::error::{ApiError, EmbeddingError};
let auth_error = ApiError::Authentication("Invalid key provided".to_string());
let embedding_error = EmbeddingError::Api(auth_error);
let error: anyhow::Error = embedding_error.into();
let wrapped = error.context("Failed during vector search");
let (error_type, suggestion, exit_code) = classify_error(&wrapped);
assert_eq!(
exit_code, 2,
"Auth errors wrapped in context should still be config errors (exit code 2)"
);
assert_eq!(error_type, "config_error");
assert!(suggestion.contains("API key"));
}
#[test]
fn test_search_agent_database_error_classification() {
let error = anyhow::anyhow!("Database connection failed");
let (error_type, suggestion, exit_code) = classify_error(&error);
assert_eq!(error_type, "database");
assert!(
suggestion.contains("database") || suggestion.contains("connectivity"),
"Database error should mention database or connectivity"
);
assert_eq!(exit_code, 1, "Database errors should exit with code 1");
}
#[test]
fn test_vector_search_config_error_classification() {
let error = anyhow::anyhow!("OPENAI_API_KEY environment variable not set");
let (error_type, suggestion, exit_code) = classify_error(&error);
assert_eq!(
error_type, "config_error",
"Missing API key should be classified as config_error"
);
assert!(
suggestion.contains("configuration") || suggestion.contains("environment"),
"Config error should mention configuration or environment"
);
assert_eq!(exit_code, 2, "Config errors should exit with code 2");
}
#[test]
fn test_vector_search_sqlite_vec_classification() {
let error = anyhow::anyhow!("sqlite-vec extension not available");
let (error_type, suggestion, exit_code) = classify_error(&error);
assert_eq!(
error_type, "config_error",
"Missing sqlite-vec should be classified as config_error"
);
assert!(
suggestion.contains("configuration"),
"Config error should mention configuration"
);
assert_eq!(
exit_code, 2,
"Missing extension is a config error, not runtime error"
);
}
#[test]
fn test_dual_output_format() {
use maproom::cli::format::format_agent_error;
let error = anyhow::anyhow!("Database connection timeout");
let (error_type, suggestion, exit_code) = classify_error(&error);
let structured_output = format_agent_error(&error_type, &error.to_string(), &suggestion);
assert_eq!(exit_code, 1, "Database errors should exit with code 1");
assert!(
structured_output.starts_with("ERROR | "),
"Structured output should start with 'ERROR | '"
);
assert!(
structured_output.contains(&format!("type={}", error_type)),
"Structured output should contain error type"
);
assert!(
structured_output.contains("message="),
"Structured output should contain message field"
);
assert!(
structured_output.contains("suggestion="),
"Structured output should contain suggestion field"
);
}
#[test]
fn test_exactly_one_error_line() {
use maproom::cli::format::format_agent_error;
let error_msg = "First line\nSecond line\nThird line";
let suggestion = "Try this\nor that";
let structured_output = format_agent_error("database", error_msg, suggestion);
let line_count = structured_output.lines().count();
assert_eq!(
line_count, 1,
"Structured error output must be exactly one line, got {} lines",
line_count
);
assert!(
!structured_output.contains('\n'),
"Structured output should not contain newline characters"
);
}
#[test]
fn test_default_format_no_structured_output() {
let error = anyhow::anyhow!("Some random error");
let (error_type, suggestion, exit_code) = classify_error(&error);
assert!(!error_type.is_empty(), "Error type should not be empty");
assert!(!suggestion.is_empty(), "Suggestion should not be empty");
assert!(
exit_code == 1 || exit_code == 2,
"Exit code should be 1 or 2"
);
}
#[test]
fn test_error_line_format_validation() {
use maproom::cli::format::format_agent_error;
use regex::Regex;
let pattern = Regex::new(r"^ERROR \| type=[a-z_]+ \| message=.+ \| suggestion=.+$")
.expect("Valid regex pattern");
let test_cases = vec![
("database", "Connection failed", "Check connectivity"),
(
"embedding_provider",
"API key missing",
"Set OPENAI_API_KEY",
),
("config_error", "sqlite-vec not found", "Install extension"),
("unknown", "Mysterious error", "Report this issue"),
("validation", "Invalid input", "Check parameters"),
("timeout", "Request timed out", "Retry the operation"),
("not_found", "Chunk not found", "Verify chunk ID is indexed"),
];
for (error_type, message, suggestion) in test_cases {
let structured_output = format_agent_error(error_type, message, suggestion);
assert!(
pattern.is_match(&structured_output),
"Error type '{}' produced invalid format: {}",
error_type,
structured_output
);
}
}
}