1use anyhow::Result;
2use chrono::DateTime;
3
4use crate::cli::ResolvedFilter;
5use crate::ui;
6use claudex::index::{IndexStore, SearchFtsOptions};
7use claudex::parser::{parse_session, stream_records};
8use claudex::providers::enabled_default;
9use claudex::store::{SessionStore, decode_project_name, short_name};
10
11pub struct SearchCommand<'a> {
12 pub query: &'a str,
13 pub project: Option<&'a str>,
14 pub limit: usize,
15 pub json: bool,
16 pub case_sensitive: bool,
17 pub role: Option<&'a str>,
18 pub tool: Option<&'a str>,
19 pub file: Option<&'a str>,
20 pub pr: Option<&'a str>,
21 pub context: usize,
22 pub no_index: bool,
23 pub filter: &'a ResolvedFilter,
24}
25
26pub fn run(opts: SearchCommand<'_>) -> Result<()> {
27 if !opts.no_index
29 && !opts.case_sensitive
30 && let Ok(()) = run_indexed(&opts)
31 {
32 return Ok(());
33 }
34 run_from_files(&opts)
35}
36
37fn run_indexed(opts: &SearchCommand<'_>) -> Result<()> {
38 let providers = enabled_default()?;
39 let mut idx = IndexStore::open()?;
40 idx.ensure_fresh(&providers)?;
41 if opts.pr.is_some() {
42 idx.ensure_pr_links_fresh(&providers)?;
43 }
44
45 let hits = idx.search_fts(SearchFtsOptions {
46 query: opts.query,
47 project_filter: opts.project,
48 filter: opts.filter,
49 role_filter: opts.role,
50 tool_filter: opts.tool,
51 file_filter: opts.file,
52 pr_filter: opts.pr,
53 context: opts.context,
54 limit: opts.limit,
55 })?;
56
57 if opts.json {
58 let output: Vec<_> = hits
59 .iter()
60 .map(|hit| {
61 let message_timestamp = hit
62 .message_timestamp_ms
63 .and_then(DateTime::from_timestamp_millis)
64 .map(|d| d.to_rfc3339());
65 serde_json::json!({
66 "provider": hit.provider,
67 "project": hit.project_name,
68 "session_id": hit.session_id,
69 "message_timestamp": message_timestamp,
70 "message_type": hit.message_type,
71 "snippet": hit.snippet,
72 "rank": hit.rank,
73 "context_before": hit.context_before.iter().map(context_json).collect::<Vec<_>>(),
74 "context_after": hit.context_after.iter().map(context_json).collect::<Vec<_>>(),
75 })
76 })
77 .collect();
78 println!("{}", serde_json::to_string_pretty(&output)?);
79 return Ok(());
80 }
81
82 if hits.is_empty() {
83 println!("No matches found for {:?}", opts.query);
84 return Ok(());
85 }
86
87 let show_provider = ui::spans_providers(hits.iter().map(|h| h.provider.as_str()));
88 for hit in &hits {
89 let date_str = hit
90 .message_timestamp_ms
91 .and_then(DateTime::from_timestamp_millis)
92 .map(|d| d.format("%Y-%m-%d").to_string())
93 .unwrap_or_else(|| "-".to_string());
94 let sid: String = hit
95 .session_id
96 .as_deref()
97 .unwrap_or("-")
98 .chars()
99 .take(8)
100 .collect();
101 let project_display = short_name(&hit.project_name);
102
103 let prefix = if show_provider {
104 format!("{} ", ui::record_type(&hit.provider))
105 } else {
106 String::new()
107 };
108 println!(
109 "{prefix}{} {} [{}] {}",
110 ui::project_headline(&project_display),
111 ui::session_id(&sid),
112 ui::timestamp(&date_str),
113 ui::role(&hit.message_type),
114 );
115 println!(" {}", render_indexed_snippet(&hit.snippet));
116 for ctx in &hit.context_before {
117 println!(
118 " {} {}",
119 ui::role(&ctx.message_type),
120 truncate_context(&ctx.content)
121 );
122 }
123 for ctx in &hit.context_after {
124 println!(
125 " {} {}",
126 ui::role(&ctx.message_type),
127 truncate_context(&ctx.content)
128 );
129 }
130 println!();
131 }
132 Ok(())
133}
134
135fn run_from_files(opts: &SearchCommand<'_>) -> Result<()> {
136 opts.filter.ensure_no_index_supported()?;
137
138 let store = SessionStore::new()?;
139 let files = store.all_session_files(opts.project)?;
140
141 let query_cmp = if opts.case_sensitive {
142 opts.query.to_string()
143 } else {
144 opts.query.to_lowercase()
145 };
146
147 let mut found = 0usize;
148 let mut json_hits = Vec::new();
149
150 'outer: for (project_raw, path) in &files {
151 let stats = if !opts.filter.is_unfiltered()
155 || opts.tool.is_some()
156 || opts.file.is_some()
157 || opts.pr.is_some()
158 {
159 match parse_session(path) {
160 Ok(stats) => Some(stats),
161 Err(_) => continue,
162 }
163 } else {
164 None
165 };
166 if let Some(stats) = &stats
167 && !opts.filter.matches("claude", stats, false)
168 {
169 continue;
170 }
171 if let Some(tool) = opts.tool
172 && stats.as_ref().is_none_or(|s| {
173 !s.tool_names
174 .iter()
175 .any(|name| name.to_lowercase().contains(&tool.to_lowercase()))
176 })
177 {
178 continue;
179 }
180 if let Some(file) = opts.file
181 && stats.as_ref().is_none_or(|s| {
182 !s.file_paths_modified
183 .iter()
184 .any(|path| path.to_lowercase().contains(&file.to_lowercase()))
185 })
186 {
187 continue;
188 }
189 if let Some(pr) = opts.pr
190 && stats.as_ref().is_none_or(|s| {
191 let needle = pr.to_lowercase();
192 !s.pr_links.iter().any(|(number, url, repo, _)| {
193 number.to_string().contains(&needle)
194 || url.to_lowercase().contains(&needle)
195 || repo.to_lowercase().contains(&needle)
196 })
197 })
198 {
199 continue;
200 }
201
202 let project_display = short_name(&decode_project_name(project_raw));
203 let mut session_date = None;
204 let mut session_id: Option<String> = None;
205 let mut stop = false;
206
207 stream_records(path, |record| {
208 if session_id.is_none()
209 && let Some(sid) = record["sessionId"].as_str()
210 {
211 session_id = Some(sid.to_string());
212 }
213 if session_date.is_none()
214 && let Some(ts) = record["timestamp"].as_str()
215 {
216 session_date = DateTime::parse_from_rfc3339(ts)
217 .ok()
218 .map(|d| d.with_timezone(&chrono::Utc));
219 }
220
221 let (role, text) = match record["type"].as_str().unwrap_or("") {
222 "user" => {
223 let content = record["message"]["content"].as_str().unwrap_or("");
224 ("user", content.to_string())
225 }
226 "assistant" => {
227 let blocks = record["message"]["content"].as_array();
228 let text = blocks
229 .map(|arr| {
230 arr.iter()
231 .filter(|b| b["type"].as_str() == Some("text"))
232 .map(|b| b["text"].as_str().unwrap_or("").to_string())
233 .collect::<Vec<_>>()
234 .join(" ")
235 })
236 .unwrap_or_default();
237 ("assistant", text)
238 }
239 _ => return true,
240 };
241 if let Some(role_filter) = opts.role
242 && role != role_filter
243 {
244 return true;
245 }
246
247 if text.is_empty() {
248 return true;
249 }
250
251 let haystack = if opts.case_sensitive {
252 text.as_str().to_string()
253 } else {
254 text.to_lowercase()
255 };
256
257 if !haystack.contains(&query_cmp) {
258 return true;
259 }
260
261 let date_str = session_date
262 .map(|d| d.format("%Y-%m-%d").to_string())
263 .unwrap_or_else(|| "-".to_string());
264 let sid: String = session_id
265 .as_deref()
266 .unwrap_or("-")
267 .chars()
268 .take(8)
269 .collect();
270
271 if !opts.json {
272 println!(
273 "{} {} [{}] {}",
274 ui::project_headline(&project_display),
275 ui::session_id(&sid),
276 ui::timestamp(&date_str),
277 ui::role(role),
278 );
279 }
280
281 for line in text.lines() {
282 let line_cmp = if opts.case_sensitive {
283 line.to_string()
284 } else {
285 line.to_lowercase()
286 };
287 if line_cmp.contains(&query_cmp) {
288 let snippet = build_file_scan_snippet(line, opts.query, opts.case_sensitive);
289 if opts.json {
290 json_hits.push(serde_json::json!({
291 "project": decode_project_name(project_raw),
292 "session_id": session_id,
293 "message_timestamp": session_date.map(|d| d.to_rfc3339()),
294 "message_type": role,
295 "snippet": snippet,
296 "rank": serde_json::Value::Null,
297 }));
298 } else {
299 print_highlighted(line, opts.query, opts.case_sensitive);
300 println!();
301 }
302 found += 1;
303 if found >= opts.limit {
304 stop = true;
305 return false;
306 }
307 }
308 }
309 true
310 })?;
311
312 if stop {
313 break 'outer;
314 }
315 }
316
317 if opts.json {
318 println!("{}", serde_json::to_string_pretty(&json_hits)?);
319 return Ok(());
320 }
321
322 if found == 0 {
323 println!("No matches found for {:?}", opts.query);
324 }
325 Ok(())
326}
327
328fn context_json(ctx: &claudex::index::SearchContextMessage) -> serde_json::Value {
329 let message_timestamp = ctx
330 .timestamp_ms
331 .and_then(DateTime::from_timestamp_millis)
332 .map(|d| d.to_rfc3339());
333 serde_json::json!({
334 "message_timestamp": message_timestamp,
335 "message_type": ctx.message_type,
336 "content": ctx.content,
337 })
338}
339
340fn truncate_context(content: &str) -> String {
341 const MAX: usize = 180;
342 let compact = content.split_whitespace().collect::<Vec<_>>().join(" ");
343 if compact.len() <= MAX {
344 compact
345 } else {
346 let mut end = MAX;
347 while !compact.is_char_boundary(end) {
348 end -= 1;
349 }
350 format!("{}...", &compact[..end])
351 }
352}
353
354fn print_highlighted(line: &str, query: &str, case_sensitive: bool) {
355 const MAX_LINE: usize = 300;
356 let display = if line.len() > MAX_LINE {
357 let mut end = MAX_LINE;
358 while !line.is_char_boundary(end) {
359 end -= 1;
360 }
361 &line[..end]
362 } else {
363 line
364 };
365
366 let haystack = if case_sensitive {
367 display.to_string()
368 } else {
369 display.to_lowercase()
370 };
371 let needle = if case_sensitive {
372 query.to_string()
373 } else {
374 query.to_lowercase()
375 };
376
377 let mut result = String::new();
378 let mut last = 0usize;
379 let mut search_from = 0usize;
380
381 while let Some(rel) = haystack[search_from..].find(&needle) {
382 let pos = search_from + rel;
383 let end = pos + needle.len();
384
385 if !display.is_char_boundary(pos) || !display.is_char_boundary(end) {
386 search_from = pos + 1;
387 continue;
388 }
389
390 result.push_str(&display[last..pos]);
391 let matched = &display[pos..end];
392 result.push_str(&ui::match_highlight(matched));
393 last = end;
394 search_from = end;
395 }
396 result.push_str(&display[last..]);
397 println!(" {}", result);
398}
399
400fn render_indexed_snippet(snippet: &str) -> String {
401 let mut out = String::new();
402 let mut rest = snippet;
403 while let Some(start) = rest.find("[[") {
404 let (before, after_start) = rest.split_at(start);
405 out.push_str(before);
406 let after_start = &after_start[2..];
407 if let Some(end) = after_start.find("]]") {
408 let (matched, after_end) = after_start.split_at(end);
409 out.push_str(&ui::match_highlight(matched));
410 rest = &after_end[2..];
411 } else {
412 out.push_str(after_start);
413 rest = "";
414 }
415 }
416 out.push_str(rest);
417 out
418}
419
420fn build_file_scan_snippet(line: &str, query: &str, case_sensitive: bool) -> String {
421 const CONTEXT: usize = 80;
422 let haystack = if case_sensitive {
423 line.to_string()
424 } else {
425 line.to_lowercase()
426 };
427 let needle = if case_sensitive {
428 query.to_string()
429 } else {
430 query.to_lowercase()
431 };
432 let Some(pos) = haystack.find(&needle) else {
433 return line.to_string();
434 };
435 let match_end = pos + needle.len();
436
437 let mut window_start = pos.saturating_sub(CONTEXT);
438 while window_start > 0 && !line.is_char_boundary(window_start) {
439 window_start -= 1;
440 }
441 let mut window_end = (match_end + CONTEXT).min(line.len());
442 while window_end < line.len() && !line.is_char_boundary(window_end) {
443 window_end += 1;
444 }
445 let prefix = if window_start > 0 { "..." } else { "" };
446 let suffix = if window_end < line.len() { "..." } else { "" };
447
448 if line.is_char_boundary(pos) && line.is_char_boundary(match_end) {
451 format!(
452 "{prefix}{}[[{}]]{}{suffix}",
453 &line[window_start..pos],
454 &line[pos..match_end],
455 &line[match_end..window_end],
456 )
457 } else {
458 format!("{prefix}{}{suffix}", &line[window_start..window_end])
459 }
460}