use crate::util::{dirs_home, parse_harness, short_id};
use anyhow::Result;
use cv_core::ir::truncate;
use cv_core::sanitize::sanitize_line;
use cv_search::{Found, Note, Source};
use std::io::Write;
use std::path::PathBuf;
pub(crate) fn cmd_search(
query: &str,
harness: Option<String>,
limit: usize,
semantic: bool,
scan: bool,
kind: Option<String>,
json: bool, ) -> Result<()> {
let want = parse_harness(&harness)?;
let scope = match kind.as_deref() {
None => cv_search::Scope::Exchange,
Some(k) => match cv_search::Scope::parse(k) {
Some(scope) => scope,
None => return crate::util::usage(format!("unknown --kind {k:?}: exchange, context or all")),
},
};
let source = if semantic {
Source::Semantic
} else {
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 scan {
eprintln!("(--scan: scanning transcripts live, head-capped; `cv index` makes search instant)");
Source::Live
} else {
Source::Index
}
};
let filter = cv_search::SearchFilter {
harness: want.map(|h| h.as_str()),
cwd: None,
scope,
};
let found = match cv_search::Searcher::default().search(query, filter, limit, source) {
Ok(found) => found,
Err(e) => {
return match source {
Source::Semantic => Err(e.context("semantic search failed (run `cv index --semantic` first?)")),
Source::Index if cv_search::is_invalid_text_query(&e) => crate::util::usage(format!("{e:#}")),
Source::Index => Err(e.context(
"full-text search needs the index (or pass --scan for a slow live scan of each session's head)",
)),
Source::Live => Err(e),
}
}
};
render(found, limit, query, json)
}
fn legacy_sqlite_index_path() -> Option<PathBuf> {
cv_core::env::var_os("CLUSTERVISION_HOME")
.map(PathBuf::from)
.or_else(|| dirs_home().map(|h| h.join(".clustervision")))
.map(|d| d.join("index.sqlite"))
}
fn note_line(note: &Note) -> Option<String> {
match note {
Note::Stale(s) => Some(format!("(note: {s})")),
Note::SnippetsUnavailable(e) => Some(format!(
"(note: live snippets unavailable ({e}); showing indexed previews)"
)),
Note::NoSubagents => None,
}
}
fn render(found: Found, limit: usize, query: &str, json: bool) -> Result<()> {
let Found {
hits,
source,
notes,
truncated,
} = found;
if json {
for line in notes.iter().filter_map(note_line) {
eprintln!("{line}");
}
let stdout = std::io::stdout();
let mut out = std::io::BufWriter::new(stdout.lock());
let rows = std::cell::LazyCell::new(cv_core::rows::SearchRowContext::new);
write_json_rows(&mut out, &hits, |hit| cv_search::row(hit, &rows))?;
out.flush()?;
if truncated {
eprintln!("(stopped at {limit} hits; use --limit)");
}
return Ok(());
}
if hits.is_empty() && source != Source::Live {
let hint = match source {
Source::Semantic => "(semantic; run `cv index --semantic` to (re)build embeddings)",
_ => "(index; try `cv index` to refresh)",
};
println!("no matches for {query:?} {hint}");
for note in ¬es {
match note {
Note::NoSubagents => println!(
"(note: sub-agent transcripts aren't searched yet — rebuild with \
`cv index --subagents` to include the Agent/Workflow lanes)"
),
other => println!("{}", note_line(other).unwrap_or_default()),
}
}
return Ok(());
}
for h in &hits {
let title = sanitize_line(h.title.as_deref().unwrap_or_default());
if source == Source::Live {
let date = h
.updated_at
.and_then(|t| crate::util::fmt_local_ts(t, "%Y-%m-%d"))
.unwrap_or_else(|| "----------".into());
println!("{:8} {:8} {:10} {}", h.harness, short_id(&h.id), date, title);
println!(" … {}", sanitize_line(&h.snippet));
continue;
}
let date = h
.updated_at
.or(h.created_at)
.and_then(|t| crate::util::fmt_local_ts(t, "%Y-%m-%d"))
.unwrap_or_else(|| "----------".into());
let (id_disp, provenance) = match h.agent_id.as_deref() {
Some(aid) => {
let parent = h.parent_id.as_deref().map(short_id).unwrap_or_default();
let wf = h.workflow.as_deref().map(|w| format!(" · ⟐{w}")).unwrap_or_default();
(short_id(aid), format!(" ⤷ sub-agent of {parent}{wf}"))
}
None => (short_id(&h.id), String::new()),
};
println!("{:8} {:8} {:10} {}{}", h.harness, id_disp, date, title, provenance);
if !h.snippet.trim().is_empty() {
println!(" … {}", truncate(&sanitize_line(&h.snippet), 120));
}
}
if truncated {
println!("\n(stopped at {limit} hits; use --limit)");
} else if hits.is_empty() {
println!("no matches for {query:?}");
}
for line in notes.iter().filter_map(note_line) {
println!("{line}");
}
Ok(())
}
struct JsonRows<'a, T, F> {
rows: &'a [T],
row: F,
}
impl<T, F: Fn(&T) -> serde_json::Value> serde::Serialize for JsonRows<'_, T, F> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let mut sequence = serializer.serialize_seq(Some(self.rows.len()))?;
for row in self.rows {
sequence.serialize_element(&(self.row)(row))?;
}
sequence.end()
}
}
fn write_json_rows<T>(out: &mut impl Write, rows: &[T], row: impl Fn(&T) -> serde_json::Value) -> Result<()> {
serde_json::to_writer_pretty(&mut *out, &JsonRows { rows, row })?;
writeln!(out)?;
Ok(())
}
pub(crate) fn cmd_index(semantic: bool, rebuild: bool, subagents: bool, strict: bool, json: bool) -> Result<()> {
if semantic && subagents {
return crate::util::usage("--semantic indexes top-level sessions; --subagents currently applies only to full-text indexing. Run `cv index --subagents` and `cv index --semantic` separately.");
}
eprintln!(
"✦ {} full-text index{}…",
if rebuild { "rebuilding" } else { "updating" },
if subagents { " (+ sub-agent forest)" } else { "" }
);
let (indexed, refs) = cv_search::index_all(None, rebuild, subagents, strict)?;
let dir = cv_search::default_tantivy_dir();
if !json {
println!(
"indexed {} top-level session(s) → {}{}",
indexed.total,
dir.display(),
if subagents {
" (sub-agent transcripts folded in)"
} else {
""
}
);
}
let mut embedded = None;
if semantic {
eprintln!("✦ refreshing semantic vectors (downloads a small model on first use)…");
let e = cv_search::embed_sessions(None, &refs)?;
if !json {
println!(
"embedded {e} session(s) → {}",
cv_search::default_embeddings_path().display()
);
}
embedded = Some(e);
}
if json {
let mut out = serde_json::json!({
"indexed": indexed.total,
"path": dir,
"unreadable": indexed.unreadable,
});
if let Some(e) = embedded {
out["embedded"] = e.into();
}
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
println!("events: extracted on the same pass → try `cv events <id>` / `cv touched <path>`");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsupported_semantic_forest_combination_fails_before_index_work() {
for rebuild in [false, true] {
let error = cmd_index(true, rebuild, true, false, false).unwrap_err();
assert!(error.is::<crate::util::UsageError>());
assert!(error.to_string().contains("top-level"));
assert!(error.to_string().contains("cv index --subagents"));
assert!(error.to_string().contains("cv index --semantic"));
}
}
#[test]
fn streamed_search_json_preserves_exact_pretty_row_array() {
for rows in [
Vec::new(),
vec![
serde_json::json!({"id":"one","text":"雪\n\""}),
serde_json::json!({"id":"two","score":null,"fields":[1,true,"é"]}),
],
] {
let mut output = Vec::new();
write_json_rows(&mut output, &rows, Clone::clone).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
format!("{}\n", serde_json::to_string_pretty(&rows).unwrap())
);
}
let rows = vec![1, 2, 3];
let visits = std::cell::RefCell::new(Vec::new());
let mut out = Vec::new();
write_json_rows(&mut out, &rows, |row| {
visits.borrow_mut().push(*row);
serde_json::json!(row)
})
.unwrap();
assert_eq!(*visits.borrow(), rows);
}
}