1use crate::core::config::{self, try_team_salt};
5use crate::search::{SearchHit, SearchQuery};
6use crate::shell::cli::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 = Store::open(&crate::core::workspace::db_path(&ws))?;
49 store.flush_search().ok();
50 let cfg = config::load(&ws)?;
51 let salt = try_team_salt(&cfg.sync).unwrap_or([0; 32]);
52 let opts = SearchQuery {
53 query: query.to_string(),
54 since_ms: parse_since(since)?,
55 agent: agent.map(str::to_string),
56 kind: kind.map(str::to_string),
57 limit,
58 };
59 match crate::search::search(&ws.join(".kaizen"), &opts, &ws, &salt, |s, q| {
60 store.get_event(s, q)
61 }) {
62 Ok(hits) => Ok((hits, false)),
63 Err(_) => Ok((scan_fallback(&store, &ws, &opts, &salt)?, true)),
64 }
65}
66
67pub fn cmd_search_reindex(workspace: Option<&Path>) -> Result<()> {
68 let ws = workspace_path(workspace)?;
69 let store = Store::open(&crate::core::workspace::db_path(&ws))?;
70 let cfg = config::load(&ws)?;
71 let ws_str = ws.to_string_lossy().to_string();
72 let sessions = store.list_sessions(&ws_str)?;
73 let events = store.workspace_events(&ws_str)?;
74 let stats = crate::search::reindex_workspace(&ws.join(".kaizen"), &ws, &sessions, events, &cfg)
75 .context("reindex search")?;
76 println!(
77 "search reindex: {} events seen, {} docs indexed",
78 stats.events_seen, stats.docs_indexed
79 );
80 Ok(())
81}
82
83fn render_hits(hits: &[SearchHit], fallback: bool) -> Result<String> {
84 use std::fmt::Write;
85 let mut out = String::new();
86 if fallback {
87 writeln!(
88 out,
89 "warning: search index unavailable; falling back to event scan"
90 )?;
91 }
92 writeln!(out, "{:<40} {:<19} {:>7} SNIPPET", "SESSION", "TS", "SCORE")?;
93 for h in hits {
94 writeln!(
95 out,
96 "{:<40} {:<19} {:>7.3} {}",
97 h.session_id,
98 fmt_ts(h.ts_ms),
99 h.score,
100 h.snippet
101 )?;
102 }
103 Ok(out)
104}
105
106fn scan_fallback(
107 store: &Store,
108 ws: &Path,
109 opts: &SearchQuery,
110 salt: &[u8; 32],
111) -> Result<Vec<SearchHit>> {
112 let mut out = Vec::new();
113 for (session, event) in store.workspace_events(&ws.to_string_lossy())? {
114 if out.len() >= opts.limit {
115 break;
116 }
117 let Some(doc) = crate::search::extract_doc(&event, &session, ws, salt) else {
118 continue;
119 };
120 if !scan_match(&doc, opts) {
121 continue;
122 }
123 out.push(SearchHit {
124 session_id: doc.session_id,
125 seq: doc.seq,
126 ts_ms: doc.ts_ms,
127 agent: doc.agent,
128 kind: doc.kind,
129 score: 0.0,
130 snippet: crate::search::extract::snippet(&doc.text, &opts.query),
131 paths: doc.paths,
132 skills: doc.skills,
133 tokens_total: doc.tokens_total,
134 });
135 }
136 Ok(out)
137}
138
139fn scan_match(doc: &crate::search::SearchDoc, opts: &SearchQuery) -> bool {
140 let q = opts.query.trim_matches('"').to_lowercase();
141 opts.agent.as_ref().is_none_or(|a| &doc.agent == a)
142 && opts.kind.as_ref().is_none_or(|k| &doc.kind == k)
143 && opts.since_ms.is_none_or(|ms| doc.ts_ms >= ms)
144 && (doc.text.to_lowercase().contains(&q)
145 || doc.paths.iter().any(|p| opts.query.contains(p)))
146}
147
148fn parse_since(raw: Option<&str>) -> Result<Option<u64>> {
149 let Some(raw) = raw else { return Ok(None) };
150 let days = raw.trim_end_matches('d').parse::<u64>()?;
151 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
152 Ok(Some(
153 (now.as_millis() as u64).saturating_sub(days.saturating_mul(86_400_000)),
154 ))
155}