Skip to main content

kaizen/shell/
search.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! `kaizen sessions search` and `kaizen search reindex`.
3
4use crate::core::config::{self, try_team_salt};
5use crate::search::{SearchHit, SearchQuery};
6use crate::shell::cli::{open_workspace_read_store, workspace_path};
7use crate::shell::fmt::fmt_ts;
8use crate::store::Store;
9use anyhow::{Context, Result};
10use std::path::Path;
11
12pub fn cmd_sessions_search(
13    workspace: Option<&Path>,
14    query: &str,
15    since: Option<&str>,
16    agent: Option<&str>,
17    kind: Option<&str>,
18    limit: usize,
19) -> Result<()> {
20    print!(
21        "{}",
22        sessions_search_text(workspace, query, since, agent, kind, limit)?
23    );
24    Ok(())
25}
26
27pub fn sessions_search_text(
28    workspace: Option<&Path>,
29    query: &str,
30    since: Option<&str>,
31    agent: Option<&str>,
32    kind: Option<&str>,
33    limit: usize,
34) -> Result<String> {
35    let (hits, fallback) = sessions_search_hits(workspace, query, since, agent, kind, limit)?;
36    render_hits(&hits, fallback)
37}
38
39pub fn sessions_search_hits(
40    workspace: Option<&Path>,
41    query: &str,
42    since: Option<&str>,
43    agent: Option<&str>,
44    kind: Option<&str>,
45    limit: usize,
46) -> Result<(Vec<SearchHit>, bool)> {
47    let ws = workspace_path(workspace)?;
48    let store = open_workspace_read_store(&ws, false)?;
49    let cfg = config::load(&ws)?;
50    let salt = try_team_salt(&cfg.sync).unwrap_or([0; 32]);
51    let opts = SearchQuery {
52        query: query.to_string(),
53        since_ms: parse_since(since)?,
54        agent: agent.map(str::to_string),
55        kind: kind.map(str::to_string),
56        limit,
57    };
58    match crate::search::search(&ws.join(".kaizen"), &opts, &ws, &salt, |s, q| {
59        store.get_event(s, q)
60    }) {
61        Ok(hits) => Ok((hits, false)),
62        Err(e) => anyhow::bail!("search index unavailable: {e}; run `kaizen search reindex`"),
63    }
64}
65
66pub fn cmd_search_reindex(workspace: Option<&Path>) -> Result<()> {
67    let ws = workspace_path(workspace)?;
68    let store = Store::open(&crate::core::workspace::db_path(&ws))?;
69    let cfg = config::load(&ws)?;
70    let ws_str = ws.to_string_lossy().to_string();
71    let sessions = store.list_sessions(&ws_str)?;
72    let events = store.workspace_events(&ws_str)?;
73    let stats = crate::search::reindex_workspace(&ws.join(".kaizen"), &ws, &sessions, events, &cfg)
74        .context("reindex search")?;
75    println!(
76        "search reindex: {} events seen, {} docs indexed",
77        stats.events_seen, stats.docs_indexed
78    );
79    Ok(())
80}
81
82fn render_hits(hits: &[SearchHit], fallback: bool) -> Result<String> {
83    use std::fmt::Write;
84    let mut out = String::new();
85    if fallback {
86        writeln!(
87            out,
88            "warning: search index unavailable; falling back to event scan"
89        )?;
90    }
91    writeln!(out, "{:<40} {:<19} {:>7} SNIPPET", "SESSION", "TS", "SCORE")?;
92    for h in hits {
93        writeln!(
94            out,
95            "{:<40} {:<19} {:>7.3} {}",
96            h.session_id,
97            fmt_ts(h.ts_ms),
98            h.score,
99            h.snippet
100        )?;
101    }
102    Ok(out)
103}
104
105fn parse_since(raw: Option<&str>) -> Result<Option<u64>> {
106    let Some(raw) = raw else { return Ok(None) };
107    let days = raw.trim_end_matches('d').parse::<u64>()?;
108    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
109    Ok(Some(
110        (now.as_millis() as u64).saturating_sub(days.saturating_mul(86_400_000)),
111    ))
112}