use crate::util::{dirs_home, home_rel, parse_harness, short_id};
use anyhow::{Context, Result};
use cv_core::ir::{truncate, Harness};
use std::path::{Path, PathBuf};
pub(crate) fn cmd_search(query: &str, harness: Option<String>, limit: usize, semantic: bool) -> Result<()> {
let want = parse_harness(&harness)?;
if semantic {
let hits = cv_search::semantic_search(None, query, limit.saturating_mul(4))
.context("semantic search failed (run `cv index --semantic` first?)")?;
render_search_hits(&hits, want, limit, query, "semantic");
return Ok(());
}
if let Some(legacy) = legacy_sqlite_index_path() {
if legacy.exists() {
eprintln!(
"(note: legacy sqlite index no longer used; safe to delete {})",
legacy.display()
);
}
}
if cv_search::default_tantivy_dir().exists() {
match cv_search::text_search(None, query, limit.saturating_mul(4)) {
Ok(hits) => {
render_search_hits(&hits, want, limit, query, "index");
return Ok(());
}
Err(e) => eprintln!("(tantivy index unavailable: {e:#}; scanning live)"),
}
} else {
eprintln!("(no index yet — scanning live; run `cv index` for instant search)");
}
cmd_search_live(query, want, limit)
}
fn legacy_sqlite_index_path() -> Option<PathBuf> {
std::env::var_os("CLUSTERVISION_HOME")
.map(PathBuf::from)
.or_else(|| dirs_home().map(|h| h.join(".clustervision")))
.map(|d| d.join("index.sqlite"))
}
fn render_search_hits(hits: &[cv_search::Hit], want: Option<Harness>, limit: usize, query: &str, source: &str) {
let rows: Vec<&cv_search::Hit> = hits
.iter()
.filter(|h| want.is_none_or(|w| h.harness == w.as_str()))
.take(limit)
.collect();
if rows.is_empty() {
let hint = if source == "semantic" {
"(semantic; run `cv index --semantic` to (re)build embeddings)"
} else {
"(index; try `cv index` to refresh)"
};
println!("no matches for {query:?} {hint}");
return;
}
for h in rows {
let date = h
.updated_at
.or(h.created_at)
.and_then(|t| chrono::DateTime::from_timestamp(t, 0))
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "----------".into());
println!(
"{:8} {:8} {:10} {}",
h.harness,
short_id(&h.id),
date,
h.title.clone().unwrap_or_default(),
);
if !h.snippet.trim().is_empty() {
println!(" … {}", truncate(&h.snippet, 120));
}
}
}
fn cmd_search_live(query: &str, want: Option<Harness>, limit: usize) -> Result<()> {
use cv_core::{Flow, Message, MessageSink, ParseOptions, Role};
let needle = query.to_lowercase();
let mut hits = 0;
struct HaySink {
hay: String,
first_user: Option<String>,
resolver: cv_core::Resolver,
}
impl MessageSink for HaySink {
fn message(&mut self, mut m: Message) -> Flow {
m.materialize(&self.resolver);
if self.first_user.is_none() && m.role == Role::User {
if let Some(t) = m.text() {
if !t.trim().is_empty() {
self.first_user = Some(t);
}
}
}
let mut piece = String::new();
cv_core::stream::append_searchable(&mut piece, &m);
self.hay.push_str(&piece.to_lowercase());
Flow::Continue
}
}
for adapter in cv_core::harness::all() {
if want.is_some_and(|h| adapter.harness() != h) || adapter.storage_root().is_none() {
continue;
}
for r in adapter.discover()? {
let mut sink = HaySink {
hay: String::new(),
first_user: None,
resolver: cv_core::Resolver::new(Some(r.path.clone())),
};
let meta = match adapter.stream(&r, &ParseOptions::bulk(), &mut sink) {
Ok(m) => m,
Err(_) => continue,
};
if let Some(pos) = sink.hay.find(&needle) {
hits += 1;
let label = cv_core::label_from(meta.title.as_deref(), sink.first_user.as_deref());
println!(
"{:8} {:8} {:10} {}",
r.harness.as_str(),
short_id(&r.id),
r.updated_at
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "----------".into()),
label,
);
println!(" … {}", snippet(&sink.hay, pos, needle.len()));
if hits >= limit {
println!("\n(stopped at {limit} hits; use --limit)");
return Ok(());
}
}
}
}
if hits == 0 {
println!("no matches for {query:?}");
}
Ok(())
}
pub(crate) fn cmd_recall(query: &str, k: usize, harness: Option<String>) -> Result<()> {
let want = parse_harness(&harness)?;
let k = k.max(1);
let fetch = if want.is_some() { (k * 4).max(20) } else { k };
let (mut hits, mode) = match cv_search::semantic_search(None, query, fetch) {
Ok(h) => (h, "semantic"),
Err(e) => {
eprintln!(
"(semantic search unavailable: {e:#}; falling back to keyword mode — run \
`cv index --semantic` for semantic recall)"
);
(cv_search::text_search(None, query, fetch)?, "keyword")
}
};
if let Some(h) = want {
let w = h.as_str();
hits.retain(|hit| hit.harness == w);
}
hits.truncate(k);
if hits.is_empty() {
println!("no recall matches for {query:?} ({mode})");
return Ok(());
}
for hit in &hits {
let cwd = hit
.cwd
.as_deref()
.map(|c| home_rel(Path::new(c)))
.unwrap_or_else(|| "(no cwd)".into());
println!(
"{:8} {:8} {:>6.3} {} · {}",
hit.harness,
short_id(&hit.id),
hit.score,
truncate(&hit.title.clone().unwrap_or_default(), 50),
truncate(&cwd, 40),
);
let excerpt = recall_excerpt(hit, query);
for line in excerpt.lines() {
println!(" {line}");
}
}
Ok(())
}
fn recall_excerpt(hit: &cv_search::Hit, query: &str) -> String {
if !hit.snippet.trim().is_empty() {
return truncate(&hit.snippet, 200);
}
let h = Harness::parse(&hit.harness);
let Some((sref, adapter)) = cv_core::find(&hit.id, h).ok().flatten() else {
return String::new();
};
let Ok(session) = adapter.parse(&sref) else {
return String::new();
};
if session.messages.is_empty() {
return String::new();
}
let needle = query.to_lowercase();
let words: Vec<&str> = needle.split_whitespace().filter(|w| w.len() > 2).collect();
let mut best = 0usize;
let mut best_score = -1i64;
for (i, m) in session.messages.iter().enumerate() {
let t = m.text().unwrap_or_default().to_lowercase();
if t.trim().is_empty() {
continue;
}
let score = words.iter().filter(|w| t.contains(**w)).count() as i64 + if t.contains(&needle) { 5 } else { 0 };
if score > best_score {
best_score = score;
best = i;
}
}
let lo = best.saturating_sub(1);
let hi = (best + 2).min(session.messages.len());
let mut out = String::new();
for m in &session.messages[lo..hi] {
if let Some(t) = m.text() {
if !t.trim().is_empty() {
out.push_str(cv_core::render::role_label(m.role));
out.push_str(": ");
out.push_str(&truncate(&t, 100));
out.push('\n');
}
}
}
out.trim_end().to_string()
}
pub(crate) fn cmd_index(semantic: bool, rebuild: bool, subagents: bool) -> Result<()> {
eprintln!(
"✦ {} full-text index{}…",
if rebuild { "rebuilding" } else { "updating" },
if subagents { " (+ sub-agent forest)" } else { "" }
);
let n = cv_search::index_all(None, rebuild, subagents)?;
println!(
"indexed {n} top-level session(s) → {}{}",
cv_search::default_tantivy_dir().display(),
if subagents {
" (sub-agent transcripts folded in)"
} else {
""
}
);
if semantic {
eprintln!("✦ embedding sessions (downloads a small model on first use)…");
let e = cv_search::embed_all(None)?;
println!(
"embedded {e} session(s) → {}",
cv_search::default_embeddings_path().display()
);
}
println!("events: extracted on the same pass → try `cv events <id>` / `cv touched <path>`");
Ok(())
}
fn snippet(hay: &str, pos: usize, len: usize) -> String {
let start = pos.saturating_sub(40);
let end = (pos + len + 40).min(hay.len());
let s = &hay[floor_char(hay, start)..ceil_char(hay, end)];
truncate(&s.replace('\n', " "), 120)
}
fn floor_char(s: &str, mut i: usize) -> usize {
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
fn ceil_char(s: &str, mut i: usize) -> usize {
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}