use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use super::progress::{install_cancel_probe, spinner_callback, stop_spinner, Spinner};
use super::shared::{empty_index_hint, open_db, open_db_create, output};
use cartog_core::Compact;
use cartog_indexer as indexer;
use cartog_rag as rag;
pub fn cmd_rag_setup(json: bool, provider_config: &rag::EmbeddingProviderConfig) -> Result<()> {
let reranker_model = provider_config.reranker_model.as_deref();
let spinner = if json {
None
} else {
eprintln!(
"Downloading embedding (~80MB) + re-ranker ({}) models, one-time…",
reranker_model.unwrap_or(rag::DEFAULT_RERANKER_MODEL)
);
Spinner::start("Downloading models")
};
let embed_result = rag::setup::download_model();
let rerank_result =
rag::setup::download_cross_encoder(reranker_model, provider_config.intra_threads);
if let Some(s) = spinner {
s.stop();
}
let embed_result = embed_result?;
let rerank_result = rerank_result?;
#[derive(serde::Serialize)]
struct CombinedSetup {
embedding: rag::setup::SetupResult,
reranker: rag::setup::SetupResult,
}
let combined = CombinedSetup {
embedding: embed_result,
reranker: rerank_result,
};
output(&combined, json, None, |c| {
format!(
"Embedding model: {}\nRe-ranker model: {}\nModels ready. You can now run 'cartog rag index'.\n",
c.embedding.model_dir, c.reranker.model_dir
)
})
}
pub fn cmd_rag_index(
db_path: &Path,
path: &str,
force: bool,
json: bool,
provider_config: &rag::EmbeddingProviderConfig,
redact: indexer::RedactionConfig,
filter: &indexer::WalkFilter,
) -> Result<()> {
let root = Path::new(path);
let cancel = install_cancel_probe();
let mut provider = rag::create_embedding_provider(provider_config)?;
let db = open_db_create(db_path, provider.dimension())?;
db.reconcile_embedding_fingerprint(&rag::fingerprint_of(provider.as_ref()))
.context("failed to reconcile embedding fingerprint")?;
let spinner = Spinner::start("Indexing code graph").map(Arc::new);
let ix_cb = spinner_callback(&spinner, indexer::ProgressUpdate::label);
let ix_cb_ref: Option<indexer::ProgressCallback<'_>> =
ix_cb.as_ref().map(|f| f as &(dyn Fn(_) + Send + Sync));
let ix_cancel: indexer::CancelProbe<'_> = &cancel;
let index_res = indexer::index_directory(
&db,
root,
false,
false,
ix_cb_ref,
Some(ix_cancel),
redact,
&std::collections::HashMap::new(),
filter,
);
drop(ix_cb);
stop_spinner(spinner);
match index_res {
Ok(_) => {}
Err(e) if indexer::is_cancelled(&e) => {
if !json {
eprintln!("Indexing cancelled; the index was left unchanged.");
}
return Ok(());
}
Err(e) => return Err(e),
}
let spinner = Spinner::start("Embedding symbols").map(Arc::new);
let rag_cb = spinner_callback(&spinner, rag::indexer::ProgressUpdate::label);
let rag_cb_ref: Option<rag::indexer::ProgressCallback<'_>> =
rag_cb.as_ref().map(|f| f as &(dyn Fn(_) + Send + Sync));
let rag_cancel: rag::indexer::CancelProbe<'_> = &cancel;
let embed_res =
rag::indexer::index_embeddings(&db, provider.as_mut(), force, rag_cb_ref, Some(rag_cancel));
drop(rag_cb);
stop_spinner(spinner);
let result = match embed_res {
Ok(r) => r,
Err(e) if indexer::is_cancelled(&e) => {
if !json {
eprintln!("Embedding cancelled; re-run to finish.");
}
return Ok(());
}
Err(e) => return Err(e),
};
output(&result, json, None, |r| {
format!(
"Embedded {} symbols ({} skipped, {} total with content)\n",
r.symbols_embedded, r.symbols_skipped, r.total_content_symbols
)
})
}
#[allow(clippy::too_many_arguments)]
pub fn cmd_rag_search(
db_path: &Path,
query: &str,
kind: Option<crate::cli::SymbolKindFilter>,
limit: u32,
json: bool,
compact: bool,
token_budget: Option<u32>,
provider_config: &rag::EmbeddingProviderConfig,
tuning: &rag::search::SearchTuning,
) -> Result<()> {
let mut provider = rag::create_embedding_provider(provider_config)?;
let db = open_db(db_path, provider.dimension())?;
let kind_filter = match kind {
Some(crate::cli::SymbolKindFilter::All) => rag::search::KindFilter::All,
Some(k) => rag::search::KindFilter::Exact(cartog_core::SymbolKind::from(k)),
None => rag::search::KindFilter::CodeOnly,
};
let reranker_factory = if provider_config.reranker_provider == "none" {
None
} else {
let name = provider_config.reranker_provider.clone();
let model = provider_config.reranker_model.clone();
let threads = provider_config.intra_threads;
Some(move || rag::create_reranker_provider(&name, model.as_deref(), threads))
};
let mut search_result = rag::search::hybrid_search_tuned_lazy(
&db,
query,
limit,
kind_filter,
provider.as_mut(),
reranker_factory,
tuning,
)?;
if compact {
search_result.compact_in_place();
}
db.log_query("rag_search", "cli");
let query = query.to_string();
let embeddings_built = db.embedding_count().map(|n| n > 0).unwrap_or(false);
output(&search_result, json, token_budget, |sr| {
render_rag_search(sr, &query, embeddings_built)
})
}
fn render_rag_search(
sr: &rag::search::HybridSearchResult,
query: &str,
embeddings_built: bool,
) -> String {
if sr.results.is_empty() {
let mut out = format!("No results found for '{query}'\n");
if !embeddings_built {
out.push_str("Hint: run 'cartog rag index' to build the semantic search index.\n");
}
return out;
}
let mut out = format!(
"Found {} results (FTS: {}, vector: {}, merged: {})\n",
sr.results.len(),
sr.fts_count,
sr.vec_count,
sr.merged_count
);
if !embeddings_built {
out.push_str(
"Hint: keyword (FTS) matches only — run 'cartog rag index' to enable semantic search.\n",
);
}
out.push('\n');
for (i, r) in sr.results.iter().enumerate() {
let sources = r
.sources
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join("+");
let rerank_str = r
.rerank_score
.map(|s| format!(" rerank={s:.2}"))
.unwrap_or_default();
out.push_str(&format!(
"{}. {} {} {}:{}-{} [{}] score={:.4}{rerank_str}\n",
i + 1,
r.symbol.kind,
r.symbol.name,
r.symbol.file_path,
r.symbol.start_line,
r.symbol.end_line,
sources,
r.rrf_score,
));
if let Some(ref content) = r.content {
let preview: String = content
.lines()
.take(3)
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n");
out.push_str(&format!("{preview}\n\n"));
}
}
out
}
pub fn cmd_context(
db_path: &Path,
task: &str,
tokens: u32,
json: bool,
compact: bool,
provider_config: &rag::EmbeddingProviderConfig,
tuning: &rag::search::SearchTuning,
) -> Result<()> {
let mut provider = rag::create_embedding_provider(provider_config)?;
let db = open_db(db_path, provider.dimension())?;
let mut ctx = {
let mut reranker = if provider_config.reranker_provider == "none" {
None
} else {
rag::create_reranker_provider(
&provider_config.reranker_provider,
provider_config.reranker_model.as_deref(),
provider_config.intra_threads,
)
};
let opts = rag::context::ContextOptions {
tuning: *tuning,
..Default::default()
};
match reranker.as_mut() {
Some(r) => rag::context::build_task_context(
&db,
task,
tokens,
provider.as_mut(),
Some(r.as_mut()),
&opts,
),
None => {
rag::context::build_task_context(&db, task, tokens, provider.as_mut(), None, &opts)
}
}?
};
if !ctx.entries.is_empty() {
db.log_query("context", "cli");
}
if compact {
ctx.compact_in_place();
}
let task = task.to_string();
output(&ctx, json, None, |ctx| {
if ctx.entries.is_empty() {
return format!("No context found for '{task}'{}\n", empty_index_hint(&db));
}
let mut out = format!(
"Context for '{task}' ({} symbols, ~{} tokens)\n\n",
ctx.entries.len(),
ctx.approx_tokens
);
for entry in &ctx.entries {
out.push_str(&format!(
"[{reason:?}] {kind} {name} {file}:{line}\n",
reason = entry.reason,
kind = entry.symbol.kind,
name = entry.symbol.name,
file = entry.symbol.file_path,
line = entry.symbol.start_line,
));
if let Some(body) = &entry.content {
for l in body.lines() {
out.push_str(&format!(" {l}\n"));
}
out.push('\n');
}
}
out
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fts_only_search_result() -> rag::search::HybridSearchResult {
rag::search::HybridSearchResult {
results: vec![rag::search::SearchResult {
symbol: cartog_core::Symbol::new(
"foo",
cartog_core::SymbolKind::Function,
"a.py",
1,
10,
0,
100,
None,
),
content: None,
rrf_score: 0.016,
rerank_score: None,
sources: vec![],
}],
fts_count: 1,
vec_count: 0,
merged_count: 1,
}
}
#[test]
fn rag_search_render_hints_when_results_found_but_embeddings_missing() {
let out = render_rag_search(&fts_only_search_result(), "foo", false);
assert!(
out.contains("cartog rag index"),
"FTS-only results without embeddings must hint at 'cartog rag index'; got:\n{out}"
);
}
#[test]
fn rag_search_render_no_hint_when_embeddings_built() {
let out = render_rag_search(&fts_only_search_result(), "foo", true);
assert!(
!out.contains("Hint:"),
"vec_count == 0 with a built index is legitimate, no hint expected; got:\n{out}"
);
}
}