use anyhow::{bail, Context, Result};
use clap::Args;
use colored::Colorize;
use serde::Serialize;
use std::path::PathBuf;
use super::OutputConfig;
use crate::access::RepoFilter;
use crate::config::Config;
use crate::index::Embedder;
use crate::search::{HybridSearch, SemanticSearch};
use crate::storage::{MetadataStore, VectorStore};
use crate::tags::{build_tag_exclude_filter, build_tag_include_filter};
use crate::types::{source_kind, ChunkType, MatchType, SearchResult};
#[derive(Args)]
pub struct SearchArgs {
query: String,
#[arg(long, short = 't')]
r#type: Option<String>,
#[arg(long, short = 'n', default_value = "10")]
limit: usize,
#[arg(long, short = 'm', default_value = "hybrid")]
mode: SearchMode,
#[arg(long, short = 'r')]
repo: Option<String>,
#[arg(long, short = 'g')]
group: Option<String>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long = "exclude-tag")]
exclude_tags: Vec<String>,
#[arg(default_value = ".")]
path: PathBuf,
}
#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
pub enum SearchMode {
#[default]
Hybrid,
Semantic,
Keyword,
}
#[derive(Serialize)]
struct SearchOutput {
query: String,
mode: String,
#[serde(skip_serializing_if = "Option::is_none")]
r#type: Option<String>,
limit: usize,
count: usize,
results: Vec<SearchResultOutput>,
}
#[derive(Serialize)]
struct SearchResultOutput {
file_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
chunk_type: String,
source: String,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<String>,
start_line: u32,
end_line: u32,
score: f32,
#[serde(skip_serializing_if = "Option::is_none")]
match_type: Option<String>,
language: String,
#[serde(skip_serializing_if = "Option::is_none")]
content_preview: Option<String>,
}
fn resolve_repo(parsed: &crate::search::query::ParsedQuery, flag: Option<&str>) -> Option<String> {
parsed
.filters
.iter()
.find(|f| {
f.field == crate::search::query::FilterField::Repo && !f.negated && f.values.len() == 1
})
.map(|f| f.values[0].clone())
.or_else(|| flag.map(str::to_string))
}
fn resolve_group(parsed: &crate::search::query::ParsedQuery, flag: Option<&str>) -> Option<String> {
crate::search::query::extract_group_filters(&parsed.filters)
.into_iter()
.next()
.or_else(|| flag.map(str::to_string))
}
fn search_text_for(parsed: &crate::search::query::ParsedQuery, raw: &str) -> String {
if parsed.text_query.is_empty() {
raw.to_string()
} else {
parsed.text_query.clone()
}
}
struct SearchExec<'a> {
lance_path: &'a std::path::Path,
mode: SearchMode,
limit: usize,
repo_filter: Option<&'a str>,
combined_filter: Option<&'a str>,
embedding: &'a crate::config::EmbeddingConfig,
model_dir: &'a std::path::Path,
semantic_weight: f32,
rrf_k: f32,
reranker: Option<crate::search::rerank::RerankerConfig>,
}
impl SearchExec<'_> {
async fn run_one(&self, query: &str, vector_store: VectorStore) -> Result<Vec<SearchResult>> {
match self.mode {
SearchMode::Keyword => vector_store
.search_fts_filtered(query, self.limit, self.repo_filter, self.combined_filter)
.await
.context("Keyword search failed"),
SearchMode::Semantic => {
let embedder = Embedder::from_config(self.embedding, self.model_dir)
.context("Failed to load embedding model")?;
let mut search = SemanticSearch::new(embedder, vector_store);
search
.search_filtered(query, self.limit, self.repo_filter, self.combined_filter)
.await
.context("Semantic search failed")
}
SearchMode::Hybrid => {
let embedder = Embedder::from_config(self.embedding, self.model_dir)
.context("Failed to load embedding model")?;
let mut search = HybridSearch::new(embedder, vector_store, self.semantic_weight)
.with_rrf_k(self.rrf_k)
.with_reranker(self.reranker.clone());
search
.search_filtered(query, self.limit, self.repo_filter, self.combined_filter)
.await
.context("Hybrid search failed")
}
}
}
async fn run_or_branches(&self, branches: &[String]) -> Result<Vec<SearchResult>> {
use std::collections::HashMap;
let mut best_by_id: HashMap<String, SearchResult> = HashMap::new();
for branch in branches {
let vector_store = VectorStore::open(self.lance_path)
.await
.context("Failed to open vector store")?;
for result in self.run_one(branch, vector_store).await? {
let id = result.chunk.id.clone();
match best_by_id.get(&id) {
Some(existing) if existing.score >= result.score => {}
_ => {
best_by_id.insert(id, result);
}
}
}
}
let mut merged: Vec<SearchResult> = best_by_id.into_values().collect();
merged.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(merged)
}
}
pub async fn run(args: SearchArgs, output: OutputConfig) -> Result<()> {
if let Some(ref server_url) = output.server {
return run_remote(args, output.clone(), server_url).await;
}
let repo_root = args
.path
.canonicalize()
.with_context(|| format!("Invalid path: {}", args.path.display()))?;
let config_path = Config::config_path(&repo_root);
if !config_path.exists() {
bail!("{}", super::not_initialized_error(&repo_root));
}
let config = Config::load(&config_path).with_context(|| "Failed to load configuration")?;
let type_filter = args
.r#type
.as_ref()
.map(|t| parse_chunk_type(t))
.transpose()?;
let lance_path = Config::lance_path(&repo_root);
let db_path = Config::db_path(&repo_root);
let model_dir = Config::model_cache_dir()?;
let vector_store = VectorStore::open(&lance_path)
.await
.context("Failed to open vector store")?;
let count = vector_store.count().await?;
if count == 0 {
if output.json {
println!(
r#"{{"error": "empty_index", "message": "No indexed content. Run `bobbin index` first."}}"#
);
} else if !output.quiet {
println!(
"{} No indexed content. Run `bobbin index` first.",
"!".yellow()
);
}
return Ok(());
}
let metadata_store = MetadataStore::open(&db_path).context("Failed to open metadata store")?;
let search_limit = if type_filter.is_some() {
args.limit * 3
} else {
args.limit
};
let parsed = crate::search::query::parse(&args.query);
let inline_repo = resolve_repo(&parsed, args.repo.as_deref());
let repo_filter = inline_repo.as_deref();
let group_arg = resolve_group(&parsed, args.group.as_deref());
let group_sql = group_arg
.as_deref()
.map(|name| {
config.group_filter(name).ok_or_else(|| {
let available: Vec<&str> = config.groups.iter().map(|g| g.name.as_str()).collect();
if available.is_empty() {
anyhow::anyhow!("Unknown group '{}'. No groups configured.", name)
} else {
anyhow::anyhow!(
"Unknown group '{}'. Available: {}",
name,
available.join(", ")
)
}
})
})
.transpose()?;
let mut extra_filters: Vec<String> = Vec::new();
extra_filters.extend(crate::search::query::filters_to_sql(&parsed.filters));
if let Some(ref g) = group_sql {
extra_filters.push(g.clone());
}
if !args.tags.is_empty() {
extra_filters.push(build_tag_include_filter(&args.tags));
}
if !args.exclude_tags.is_empty() {
extra_filters.push(build_tag_exclude_filter(&args.exclude_tags));
}
let combined_filter = if extra_filters.is_empty() {
None
} else {
Some(extra_filters.join(" AND "))
};
if matches!(args.mode, SearchMode::Semantic | SearchMode::Hybrid) {
let current_model = config.embedding.model.as_str();
if let Some(stored) = metadata_store.get_meta("embedding_model")? {
if stored != current_model {
bail!(
"Configured embedding model ({}) differs from indexed model ({}). Run `bobbin index` to re-index.",
current_model,
stored
);
}
}
}
let search_text = search_text_for(&parsed, &args.query);
let calibration = super::calibrate::load_calibration(&repo_root);
let exec = SearchExec {
lance_path: &lance_path,
mode: args.mode,
limit: search_limit,
repo_filter,
combined_filter: combined_filter.as_deref(),
embedding: &config.embedding,
model_dir: &model_dir,
semantic_weight: calibration
.as_ref()
.map(|c| c.best_config.semantic_weight)
.unwrap_or(config.search.semantic_weight),
rrf_k: calibration
.as_ref()
.map(|c| c.best_config.rrf_k)
.unwrap_or(config.search.rrf_k),
reranker: config.search.reranker.clone(),
};
let results = if parsed.has_or && parsed.or_branches.len() > 1 {
drop(vector_store);
exec.run_or_branches(&parsed.or_branches).await?
} else {
exec.run_one(&search_text, vector_store).await?
};
let access_filter = RepoFilter::from_config(&config.access, &output.role);
let mut results =
access_filter.filter_vec(results, |r| RepoFilter::repo_from_path(&r.chunk.file_path));
crate::search::query::retain_matching(&mut results, &parsed, |r| r.chunk.content.as_str());
let results = results;
let filtered_results: Vec<SearchResult> = if let Some(ref chunk_type) = type_filter {
results
.into_iter()
.filter(|r| &r.chunk.chunk_type == chunk_type)
.take(args.limit)
.collect()
} else {
results.into_iter().take(args.limit).collect()
};
if output.json {
print_json_output(
&args.query,
args.mode,
&args.r#type,
args.limit,
&filtered_results,
)?;
} else if !output.quiet {
print_human_output(&args.query, args.mode, &filtered_results, output.verbose);
}
Ok(())
}
async fn run_remote(args: SearchArgs, output: OutputConfig, server_url: &str) -> Result<()> {
use crate::http::client::Client;
let client = Client::new(server_url);
let mode_str = match args.mode {
SearchMode::Hybrid => "hybrid",
SearchMode::Semantic => "semantic",
SearchMode::Keyword => "keyword",
};
let role = output.role.clone();
let resp = client
.search(
&args.query,
mode_str,
args.r#type.as_deref(),
args.limit,
args.repo.as_deref(),
Some(&role),
)
.await?;
if output.json {
let json_output = SearchOutput {
query: resp.query,
mode: resp.mode,
r#type: args.r#type,
limit: args.limit,
count: resp.count,
results: resp
.results
.iter()
.map(|r| SearchResultOutput {
file_path: r.file_path.clone(),
name: r.name.clone(),
chunk_type: r.chunk_type.clone(),
source: r.source.clone().unwrap_or_else(|| "code".to_string()),
repo: r.repo.clone(),
start_line: r.start_line,
end_line: r.end_line,
score: r.score,
match_type: r.match_type.clone(),
language: r.language.clone(),
content_preview: Some(r.content_preview.clone()),
})
.collect(),
};
println!("{}", serde_json::to_string_pretty(&json_output)?);
} else if !output.quiet {
if resp.results.is_empty() {
println!(
"{} No results found for: {} (via {})",
"!".yellow(),
args.query.cyan(),
server_url.dimmed()
);
return Ok(());
}
println!(
"{} Found {} results for: {} ({}, via {})",
"✓".green(),
resp.results.len(),
args.query.cyan(),
resp.mode.dimmed(),
server_url.dimmed()
);
println!();
for (i, result) in resp.results.iter().enumerate() {
let name_display = result
.name
.as_ref()
.map(|n| format!(" ({})", n.cyan()))
.unwrap_or_default();
println!(
"{}. {}:{}{}",
(i + 1).to_string().bold(),
result.file_path.blue(),
result.start_line,
name_display
);
let match_info = result
.match_type
.as_ref()
.map(|mt| format!(" [{}]", mt).dimmed().to_string())
.unwrap_or_default();
let repo_tag = result
.repo
.as_ref()
.map(|r| format!("[{}] ", r).green().to_string())
.unwrap_or_default();
println!(
" {}{} {} · lines {}-{} · score {:.4}{}",
repo_tag,
result.chunk_type.magenta(),
result.language.dimmed(),
result.start_line,
result.end_line,
result.score,
match_info
);
if output.verbose {
let preview = truncate_content(&result.content_preview, 300);
for line in preview.lines().take(5) {
println!(" {}", line.dimmed());
}
if result.content_preview.lines().count() > 5 {
println!(" {}", "...".dimmed());
}
}
println!();
}
}
Ok(())
}
fn parse_chunk_type(s: &str) -> Result<ChunkType> {
match s.to_lowercase().as_str() {
"function" | "func" | "fn" => Ok(ChunkType::Function),
"method" => Ok(ChunkType::Method),
"class" => Ok(ChunkType::Class),
"struct" => Ok(ChunkType::Struct),
"enum" => Ok(ChunkType::Enum),
"interface" => Ok(ChunkType::Interface),
"module" | "mod" => Ok(ChunkType::Module),
"impl" => Ok(ChunkType::Impl),
"trait" => Ok(ChunkType::Trait),
"doc" | "documentation" => Ok(ChunkType::Doc),
"section" => Ok(ChunkType::Section),
"table" => Ok(ChunkType::Table),
"code_block" | "codeblock" => Ok(ChunkType::CodeBlock),
"commit" => Ok(ChunkType::Commit),
"issue" | "bead" => Ok(ChunkType::Issue),
"other" => Ok(ChunkType::Other),
_ => bail!(
"Unknown chunk type '{}'. Valid types: function, method, class, struct, enum, interface, module, impl, trait, doc, section, table, code_block, commit, issue, other",
s
),
}
}
fn print_json_output(
query: &str,
mode: SearchMode,
type_filter: &Option<String>,
limit: usize,
results: &[SearchResult],
) -> Result<()> {
let mode_str = match mode {
SearchMode::Hybrid => "hybrid",
SearchMode::Semantic => "semantic",
SearchMode::Keyword => "keyword",
};
let output = SearchOutput {
query: query.to_string(),
mode: mode_str.to_string(),
r#type: type_filter.clone(),
limit,
count: results.len(),
results: results
.iter()
.map(|r| SearchResultOutput {
file_path: r.chunk.file_path.clone(),
name: r.chunk.name.clone(),
chunk_type: r.chunk.chunk_type.to_string(),
source: source_kind(&r.chunk.chunk_type).to_string(),
repo: r.repo.clone(),
start_line: r.chunk.start_line,
end_line: r.chunk.end_line,
score: r.score,
match_type: r.match_type.map(|mt| match mt {
MatchType::Semantic => "semantic".to_string(),
MatchType::Keyword => "keyword".to_string(),
MatchType::Hybrid => "hybrid".to_string(),
}),
language: r.chunk.language.clone(),
content_preview: Some(truncate_content(&r.chunk.content, 200)),
})
.collect(),
};
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
fn print_human_output(query: &str, mode: SearchMode, results: &[SearchResult], verbose: bool) {
if results.is_empty() {
println!("{} No results found for: {}", "!".yellow(), query.cyan());
return;
}
let mode_str = match mode {
SearchMode::Hybrid => "hybrid",
SearchMode::Semantic => "semantic",
SearchMode::Keyword => "keyword",
};
println!(
"{} Found {} results for: {} ({})",
"✓".green(),
results.len(),
query.cyan(),
mode_str.dimmed()
);
println!();
for (i, result) in results.iter().enumerate() {
let chunk = &result.chunk;
let match_info = match (mode, result.match_type) {
(SearchMode::Hybrid, Some(MatchType::Hybrid)) => " [hybrid]".yellow().to_string(),
(SearchMode::Hybrid, Some(MatchType::Semantic)) => " [semantic]".dimmed().to_string(),
(SearchMode::Hybrid, Some(MatchType::Keyword)) => " [keyword]".dimmed().to_string(),
_ => String::new(),
};
let repo_tag = result
.repo
.as_ref()
.map(|r| format!("[{}] ", r).green().to_string())
.unwrap_or_default();
if chunk.chunk_type == ChunkType::Commit {
let name_display = chunk
.name
.as_ref()
.map(|n| n.cyan().to_string())
.unwrap_or_default();
println!(
"{}. {} {}",
(i + 1).to_string().bold(),
chunk.file_path.blue(),
name_display,
);
println!(
" {}{} · score {:.4}{}",
repo_tag,
"commit".magenta(),
result.score,
match_info
);
} else if chunk.chunk_type == ChunkType::Issue {
let name_display = chunk
.name
.as_ref()
.map(|n| n.cyan().to_string())
.unwrap_or_default();
println!(
"{}. {} {}",
(i + 1).to_string().bold(),
chunk.file_path.blue(),
name_display,
);
println!(
" {}{} · score {:.4}{}",
repo_tag,
"issue".magenta(),
result.score,
match_info
);
} else {
let name_display = chunk
.name
.as_ref()
.map(|n| format!(" ({})", n.cyan()))
.unwrap_or_default();
println!(
"{}. {}:{}{}",
(i + 1).to_string().bold(),
chunk.file_path.blue(),
chunk.start_line,
name_display
);
println!(
" {}{} {} · lines {}-{} · score {:.4}{}",
repo_tag,
chunk.chunk_type.to_string().magenta(),
chunk.language.dimmed(),
chunk.start_line,
chunk.end_line,
result.score,
match_info
);
}
if verbose {
let preview = truncate_content(&chunk.content, 300);
for line in preview.lines().take(5) {
println!(" {}", line.dimmed());
}
if chunk.content.lines().count() > 5 {
println!(" {}", "...".dimmed());
}
}
println!();
}
}
fn truncate_content(content: &str, max_len: usize) -> String {
if content.len() <= max_len {
content.to_string()
} else {
let truncated: String = content.chars().take(max_len).collect();
format!("{}...", truncated.trim_end())
}
}
#[cfg(test)]
#[path = "search_tests.rs"]
mod tests;