Skip to main content

cui/
query.rs

1//! Index-backed queries (cuj SPEC §9.5, §9.7, §9.17), returning
2//! owned data and never printing. Unlike the CLI, cui never
3//! reindexes: chains are opened read-only and queried at their
4//! pinned state; staleness is reported to the caller instead of
5//! blocking on a rebuild (or eprintln!-ing over the alternate
6//! screen).
7
8use std::collections::HashSet;
9
10use metatheca::Uuid;
11use taxopsis::{Atom, Direction, Expr, Op, OrderBy, QueryValue};
12
13use crate::error::{Error, Result};
14use crate::snapshot::Snapshot;
15
16/// A parsed `:find` argument list, mirroring `cuj find`.
17#[derive(Clone, Debug, Default, PartialEq)]
18pub struct FindSpec {
19    pub tags: Vec<String>,
20    pub categories: Vec<String>,
21    pub since: Option<String>,
22    pub until: Option<String>,
23    pub todo: bool,
24    pub filter: Option<String>,
25    /// The argument text as typed, for display.
26    pub raw: String,
27}
28
29impl FindSpec {
30    pub fn is_empty(&self) -> bool {
31        self.tags.is_empty()
32            && self.categories.is_empty()
33            && self.since.is_none()
34            && self.until.is_none()
35            && !self.todo
36            && self.filter.is_none()
37    }
38}
39
40fn atom(predicate: &str, op: Op, value: QueryValue) -> Expr {
41    Expr::Atom(Atom {
42        predicate: predicate.to_string(),
43        op,
44        value,
45    })
46}
47
48fn and(a: Expr, b: Expr) -> Expr {
49    Expr::And(Box::new(a), Box::new(b))
50}
51
52fn or(a: Expr, b: Expr) -> Expr {
53    Expr::Or(Box::new(a), Box::new(b))
54}
55
56/// The document-predicate disjunction for one construct kind
57/// (SPEC §9.5): the effective index is the union of the parsed and
58/// applied documents.
59fn both(member: &str, op: Op, value: &str) -> Expr {
60    or(
61        atom(
62            &format!("jot/index/{member}"),
63            op,
64            QueryValue::Str(value.to_string()),
65        ),
66        atom(
67            &format!("cuj/applied/{member}"),
68            op,
69            QueryValue::Str(value.to_string()),
70        ),
71    )
72}
73
74fn id_order() -> OrderBy {
75    OrderBy {
76        predicate: "cuj/id/id".into(),
77        direction: Direction::Asc,
78    }
79}
80
81/// Open the taxopsis chain read-only and report whether its pinned
82/// state lags the vault head.
83fn taxopsis_open(app: &cuj::App) -> Result<(taxopsis::Chain, bool)> {
84    let chain = cuj::ext::open_taxopsis(app.root())?;
85    let stale = chain.current_state()?.metatheca != app.vault.current_state_hash()?;
86    Ok((chain, stale))
87}
88
89fn logopsis_open(app: &cuj::App) -> Result<(logopsis::Chain, bool)> {
90    let chain = cuj::ext::open_logopsis(app.root())?;
91    let pinned = chain.get_search_state(&chain.current_hash()?)?.metatheca;
92    let stale = pinned != app.vault.current_state_hash()?;
93    Ok((chain, stale))
94}
95
96/// Typed filter over the effective index (SPEC §9.5). Returns the
97/// matching entries in ascending jot-id order, plus staleness.
98pub fn find(app: &cuj::App, spec: &FindSpec) -> Result<(Vec<Uuid>, bool)> {
99    let (chain, stale) = taxopsis_open(app)?;
100
101    let mut expr: Option<Expr> = None;
102    let mut add = |e: Expr| {
103        expr = Some(match expr.take() {
104            Some(prev) => and(prev, e),
105            None => e,
106        });
107    };
108    for t in &spec.tags {
109        add(both("tags", Op::Eq, &t.to_lowercase()));
110    }
111    for c in &spec.categories {
112        add(both("categories", Op::Prefix, &c.to_lowercase()));
113    }
114    if let Some(s) = &spec.since {
115        let ns =
116            cuj::binding::due_to_ns(s).ok_or_else(|| Error::Usage(format!("bad date {s:?}")))?;
117        add(atom("cuj/jot/created_at_ns", Op::Ge, QueryValue::Int(ns)));
118    }
119    if let Some(u) = &spec.until {
120        let ns =
121            cuj::binding::due_to_ns(u).ok_or_else(|| Error::Usage(format!("bad date {u:?}")))?;
122        add(atom("cuj/jot/created_at_ns", Op::Le, QueryValue::Int(ns)));
123    }
124    if spec.todo {
125        add(both("todos/done", Op::Eq, "false"));
126    }
127    if let Some(f) = &spec.filter {
128        add(taxopsis::parse_filter(f)?);
129    }
130    let expr = expr.ok_or_else(|| {
131        Error::Usage(
132            "find needs at least one filter (tag, category, date, --todo, --filter)".into(),
133        )
134    })?;
135
136    let sref = chain.current_hash()?.to_hex();
137    let page = chain.query(&expr, &sref, Some(&id_order()), None, None)?;
138    Ok((page.rows.iter().map(|r| r.entry).collect(), stale))
139}
140
141/// Which engine served a search.
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
143pub enum SearchEngine {
144    /// zetetes: logopsis + semopsis fused by reciprocal rank
145    /// fusion, at the pinned zetetes-state.
146    Hybrid,
147    /// logopsis BM25 alone — the fallback when the vault has no
148    /// zetetes chain.
149    Bm25,
150}
151
152/// Full-text search: hybrid via zetetes when the vault carries a
153/// zetetes chain, else logopsis BM25. Hits are mapped to their
154/// owning live jots, deduplicated keeping the best score, capped
155/// at `k`.
156///
157/// Returns the scored hits, whether the result was truncated at
158/// `k`, and which engine served the query.
159pub type SearchResult = (Vec<(Uuid, f64)>, bool, SearchEngine);
160
161pub fn search(
162    app: &cuj::App,
163    snap: &Snapshot,
164    query: &str,
165    k: usize,
166) -> Result<SearchResult> {
167    // Over-fetch to survive entity mapping and profile filtering.
168    let want = k * 4 + 16;
169    // A zetetes chain that exists but has never been advanced (no
170    // pinned sources — the shape cuj init leaves behind) serves
171    // empty result sets; treat it like an absent chain.
172    let zchain = match zetetes::Chain::open(app.root()) {
173        Ok(chain) => {
174            let state = chain.get_state(&chain.current_hash()?)?;
175            if state.semopsis.is_none() && state.logopsis.is_none() {
176                None
177            } else {
178                Some((chain, state))
179            }
180        }
181        Err(zetetes::Error::NotFound(_)) => None,
182        Err(e) => return Err(e.into()),
183    };
184    let (raw, stale, engine) = match zchain {
185        Some((mut chain, state)) => {
186            let stale = state.metatheca != app.vault.current_state_hash()?;
187            let hits = chain.query(query, want, "current")?;
188            let raw: Vec<(Uuid, f64)> = hits.into_iter().map(|h| (h.entry, h.score)).collect();
189            (raw, stale, SearchEngine::Hybrid)
190        }
191        None => {
192            let (chain, stale) = logopsis_open(app)?;
193            let expr = logopsis::parse_query(query)?;
194            let hits = chain.query(&expr, want, "current")?;
195            let raw: Vec<(Uuid, f64)> = hits.into_iter().map(|h| (h.entry, h.score)).collect();
196            (raw, stale, SearchEngine::Bm25)
197        }
198    };
199    let mut seen = HashSet::new();
200    let mut out = Vec::new();
201    for (entry, score) in raw {
202        let Some(i) = snap.owning_row(entry) else {
203            continue;
204        };
205        let jot = snap.rows[i].entry;
206        if seen.insert(jot) {
207            out.push((jot, score));
208            if out.len() >= k {
209                break;
210            }
211        }
212    }
213    Ok((out, stale, engine))
214}
215
216/// Jots whose effective index references `of` (SPEC §9.17) — a
217/// taxopsis query on the two references/entry predicates.
218pub fn backlinks(app: &cuj::App, of: Uuid) -> Result<(Vec<Uuid>, bool)> {
219    let (chain, stale) = taxopsis_open(app)?;
220    let expr = both("references/entry", Op::Eq, &of.hyphenated().to_string());
221    let sref = chain.current_hash()?.to_hex();
222    let page = chain.query(&expr, &sref, Some(&id_order()), None, None)?;
223    Ok((page.rows.iter().map(|r| r.entry).collect(), stale))
224}