cuj-tui 0.1.0

cui — a read-only TUI browser for cuj vaults
Documentation
//! Index-backed queries (cuj SPEC §9.5, §9.7, §9.17), returning
//! owned data and never printing. Unlike the CLI, cui never
//! reindexes: chains are opened read-only and queried at their
//! pinned state; staleness is reported to the caller instead of
//! blocking on a rebuild (or eprintln!-ing over the alternate
//! screen).

use std::collections::HashSet;

use metatheca::Uuid;
use taxopsis::{Atom, Direction, Expr, Op, OrderBy, QueryValue};

use crate::error::{Error, Result};
use crate::snapshot::Snapshot;

/// A parsed `:find` argument list, mirroring `cuj find`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FindSpec {
    pub tags: Vec<String>,
    pub categories: Vec<String>,
    pub since: Option<String>,
    pub until: Option<String>,
    pub todo: bool,
    pub filter: Option<String>,
    /// The argument text as typed, for display.
    pub raw: String,
}

impl FindSpec {
    pub fn is_empty(&self) -> bool {
        self.tags.is_empty()
            && self.categories.is_empty()
            && self.since.is_none()
            && self.until.is_none()
            && !self.todo
            && self.filter.is_none()
    }
}

fn atom(predicate: &str, op: Op, value: QueryValue) -> Expr {
    Expr::Atom(Atom {
        predicate: predicate.to_string(),
        op,
        value,
    })
}

fn and(a: Expr, b: Expr) -> Expr {
    Expr::And(Box::new(a), Box::new(b))
}

fn or(a: Expr, b: Expr) -> Expr {
    Expr::Or(Box::new(a), Box::new(b))
}

/// The document-predicate disjunction for one construct kind
/// (SPEC §9.5): the effective index is the union of the parsed and
/// applied documents.
fn both(member: &str, op: Op, value: &str) -> Expr {
    or(
        atom(
            &format!("jot/index/{member}"),
            op,
            QueryValue::Str(value.to_string()),
        ),
        atom(
            &format!("cuj/applied/{member}"),
            op,
            QueryValue::Str(value.to_string()),
        ),
    )
}

fn id_order() -> OrderBy {
    OrderBy {
        predicate: "cuj/id/id".into(),
        direction: Direction::Asc,
    }
}

/// Open the taxopsis chain read-only and report whether its pinned
/// state lags the vault head.
fn taxopsis_open(app: &cuj::App) -> Result<(taxopsis::Chain, bool)> {
    let chain = cuj::ext::open_taxopsis(app.root())?;
    let stale = chain.current_state()?.metatheca != app.vault.current_state_hash()?;
    Ok((chain, stale))
}

fn logopsis_open(app: &cuj::App) -> Result<(logopsis::Chain, bool)> {
    let chain = cuj::ext::open_logopsis(app.root())?;
    let pinned = chain.get_search_state(&chain.current_hash()?)?.metatheca;
    let stale = pinned != app.vault.current_state_hash()?;
    Ok((chain, stale))
}

/// Typed filter over the effective index (SPEC §9.5). Returns the
/// matching entries in ascending jot-id order, plus staleness.
pub fn find(app: &cuj::App, spec: &FindSpec) -> Result<(Vec<Uuid>, bool)> {
    let (chain, stale) = taxopsis_open(app)?;

    let mut expr: Option<Expr> = None;
    let mut add = |e: Expr| {
        expr = Some(match expr.take() {
            Some(prev) => and(prev, e),
            None => e,
        });
    };
    for t in &spec.tags {
        add(both("tags", Op::Eq, &t.to_lowercase()));
    }
    for c in &spec.categories {
        add(both("categories", Op::Prefix, &c.to_lowercase()));
    }
    if let Some(s) = &spec.since {
        let ns =
            cuj::binding::due_to_ns(s).ok_or_else(|| Error::Usage(format!("bad date {s:?}")))?;
        add(atom("cuj/jot/created_at_ns", Op::Ge, QueryValue::Int(ns)));
    }
    if let Some(u) = &spec.until {
        let ns =
            cuj::binding::due_to_ns(u).ok_or_else(|| Error::Usage(format!("bad date {u:?}")))?;
        add(atom("cuj/jot/created_at_ns", Op::Le, QueryValue::Int(ns)));
    }
    if spec.todo {
        add(both("todos/done", Op::Eq, "false"));
    }
    if let Some(f) = &spec.filter {
        add(taxopsis::parse_filter(f)?);
    }
    let expr = expr.ok_or_else(|| {
        Error::Usage(
            "find needs at least one filter (tag, category, date, --todo, --filter)".into(),
        )
    })?;

    let sref = chain.current_hash()?.to_hex();
    let page = chain.query(&expr, &sref, Some(&id_order()), None, None)?;
    Ok((page.rows.iter().map(|r| r.entry).collect(), stale))
}

/// Which engine served a search.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SearchEngine {
    /// zetetes: logopsis + semopsis fused by reciprocal rank
    /// fusion, at the pinned zetetes-state.
    Hybrid,
    /// logopsis BM25 alone — the fallback when the vault has no
    /// zetetes chain.
    Bm25,
}

/// Full-text search: hybrid via zetetes when the vault carries a
/// zetetes chain, else logopsis BM25. Hits are mapped to their
/// owning live jots, deduplicated keeping the best score, capped
/// at `k`.
///
/// Returns the scored hits, whether the result was truncated at
/// `k`, and which engine served the query.
pub type SearchResult = (Vec<(Uuid, f64)>, bool, SearchEngine);

pub fn search(
    app: &cuj::App,
    snap: &Snapshot,
    query: &str,
    k: usize,
) -> Result<SearchResult> {
    // Over-fetch to survive entity mapping and profile filtering.
    let want = k * 4 + 16;
    // A zetetes chain that exists but has never been advanced (no
    // pinned sources — the shape cuj init leaves behind) serves
    // empty result sets; treat it like an absent chain.
    let zchain = match zetetes::Chain::open(app.root()) {
        Ok(chain) => {
            let state = chain.get_state(&chain.current_hash()?)?;
            if state.semopsis.is_none() && state.logopsis.is_none() {
                None
            } else {
                Some((chain, state))
            }
        }
        Err(zetetes::Error::NotFound(_)) => None,
        Err(e) => return Err(e.into()),
    };
    let (raw, stale, engine) = match zchain {
        Some((mut chain, state)) => {
            let stale = state.metatheca != app.vault.current_state_hash()?;
            let hits = chain.query(query, want, "current")?;
            let raw: Vec<(Uuid, f64)> = hits.into_iter().map(|h| (h.entry, h.score)).collect();
            (raw, stale, SearchEngine::Hybrid)
        }
        None => {
            let (chain, stale) = logopsis_open(app)?;
            let expr = logopsis::parse_query(query)?;
            let hits = chain.query(&expr, want, "current")?;
            let raw: Vec<(Uuid, f64)> = hits.into_iter().map(|h| (h.entry, h.score)).collect();
            (raw, stale, SearchEngine::Bm25)
        }
    };
    let mut seen = HashSet::new();
    let mut out = Vec::new();
    for (entry, score) in raw {
        let Some(i) = snap.owning_row(entry) else {
            continue;
        };
        let jot = snap.rows[i].entry;
        if seen.insert(jot) {
            out.push((jot, score));
            if out.len() >= k {
                break;
            }
        }
    }
    Ok((out, stale, engine))
}

/// Jots whose effective index references `of` (SPEC §9.17) — a
/// taxopsis query on the two references/entry predicates.
pub fn backlinks(app: &cuj::App, of: Uuid) -> Result<(Vec<Uuid>, bool)> {
    let (chain, stale) = taxopsis_open(app)?;
    let expr = both("references/entry", Op::Eq, &of.hyphenated().to_string());
    let sref = chain.current_hash()?.to_hex();
    let page = chain.query(&expr, &sref, Some(&id_order()), None, None)?;
    Ok((page.rows.iter().map(|r| r.entry).collect(), stale))
}