use crate::util::usage;
use anyhow::{Context, Result};
use cv_core::query::{FieldId, SessionQuery};
pub(crate) use cv_core::query::{filter_refs, TextSets};
pub(crate) fn build(query: Option<String>) -> Result<Option<SessionQuery>> {
match query {
Some(q) => match SessionQuery::parse(&q) {
Ok(parsed) => Ok(Some(parsed)),
Err(e) => usage(e),
},
None => Ok(None),
}
}
pub(crate) fn text_sets(query: &SessionQuery) -> Result<TextSets> {
text_sets_within(query, cv_search::LiveRead::of(None))
}
fn text_sets_within(query: &SessionQuery, live: cv_search::LiveRead) -> Result<TextSets> {
let needles = query.needles(FieldId::Text);
if needles.is_empty() {
return Ok(TextSets::empty());
}
if live == cv_search::LiveRead::Frozen && !cv_search::default_tantivy_dir().exists() {
return usage("`text:` needs a full-text index; build it with `cv index`");
}
let borrowed: Vec<_> = needles.iter().map(String::as_str).collect();
let (matches, suffix) = cv_search::text_search_ids_suffixed(None, &borrowed, live, &cv_core::sessions())
.context("opening full-text query index")?;
if suffix.no_index && suffix.unread() > 0 {
return usage(format!(
"`text:` needs a full-text index: {} of {} session(s) were not read live; build it with `cv index`",
suffix.unread(),
suffix.stale
));
}
if let Some(line) = suffix.unread_line() {
eprintln!("(note: text: {line})");
}
let mut sets = std::collections::HashMap::new();
for (needle, result) in needles.into_iter().zip(matches) {
sets.insert(
needle.clone(),
result.with_context(|| format!("resolving text:{needle:?}"))?,
);
}
Ok(TextSets::resolve(query, |needle| {
sets.remove(needle).unwrap_or_default()
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unavailable_index_and_missing_index_have_distinct_query_outcomes() {
let world = crate::test_world("query-text-index-errors");
let path = world.write_session(
"lone",
&[serde_json::json!({"type":"user", "message":{"role":"user", "content":"needle"}})],
);
let query = SessionQuery::parse("NOT text:needle").unwrap();
let sets = text_sets(&query).unwrap();
assert!(sets.contains("needle", "lone"), "{}", path.display());
let absent = text_sets_within(&query, cv_search::LiveRead::Within(0)).unwrap_err();
assert!(absent.downcast_ref::<crate::util::UsageError>().is_some(), "{absent:#}");
assert!(format!("{absent:#}").contains("cv index"), "{absent:#}");
std::fs::create_dir_all(cv_search::default_tantivy_dir()).unwrap();
std::fs::write(cv_search::default_tantivy_dir().join("meta.json"), "not an index").unwrap();
assert!(
text_sets(&query).is_err(),
"existing unreadable index became false membership"
);
}
#[test]
fn text_sets_batch_distinct_needles_without_losing_membership() {
let world = crate::test_world("query_text_sets_batch");
let refs: Vec<_> = [("first", "alpha"), ("second", "beta")]
.into_iter()
.map(|(id, content)| {
let path = world.write_session(
id,
&[serde_json::json!({
"type":"user", "message":{"role":"user", "content":content}
})],
);
cv_core::SessionRef::new(id, cv_core::Harness::Claude, path)
})
.collect();
cv_search::index_sessions(None, refs, true, false).unwrap();
let query = SessionQuery::parse("text:alpha OR text:beta OR text:alpha").unwrap();
let sets = text_sets(&query).unwrap();
assert!(sets.contains("alpha", "first"));
assert!(!sets.contains("alpha", "second"));
assert!(sets.contains("beta", "second"));
assert!(!sets.contains("beta", "first"));
let invalid = SessionQuery::parse("NOT text:\"(\"").unwrap();
assert!(
text_sets(&invalid).is_err(),
"invalid full-text syntax became an empty false fact"
);
}
}