Skip to main content

claudex_cli/commands/
search.rs

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    // FTS5 is case-insensitive; fall back to file scan for case-sensitive queries
28    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        // Apply the cross-cutting --since/--until/--model filters at the session
152        // level (the indexed path filters sessions the same way). Skip the parse
153        // when no filter is active so an unfiltered search stays single-pass.
154        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    // Non-ASCII folding may shift byte offsets between `line` and the
449    // lowercased haystack; skip marker wrapping when offsets don't line up.
450    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}