Skip to main content

rec/session/
search.rs

1//! Full-text search across sessions with highlighting.
2//!
3//! Searches session names, tags, and command text using substring or regex
4//! matching. Results are grouped by session with highlighted matches.
5
6use regex::Regex;
7use serde::Serialize;
8
9use crate::cli::Output;
10use crate::error::{RecError, Result};
11use crate::session::normalize_tag;
12use crate::storage::SessionStore;
13
14/// A single command match within a session.
15#[derive(Debug, Clone, Serialize)]
16pub struct SearchMatch {
17    /// Command index within the session (0-based)
18    pub index: u32,
19    /// The full command text
20    pub command: String,
21}
22
23/// Search results for a single session.
24#[derive(Debug, Clone, Serialize)]
25pub struct SearchResult {
26    /// Session name
27    pub session: String,
28    /// Session ID
29    pub id: String,
30    /// Session tags
31    pub tags: Vec<String>,
32    /// Session start time
33    pub started_at: f64,
34    /// Matched commands
35    pub matches: Vec<SearchMatch>,
36    /// Whether the session name matched
37    pub name_matched: bool,
38    /// Whether any tag matched
39    pub tag_matched: bool,
40}
41
42/// Search all sessions for a pattern in names, tags, and commands.
43///
44/// Supports both substring (case-insensitive) and regex matching.
45/// Results are sorted by `started_at` descending (most recent first).
46///
47/// # Tag filter
48/// If `tag_filter` is non-empty, only sessions with at least one matching
49/// tag (normalized comparison) are searched.
50///
51/// # Output modes
52/// - Human: grouped output with highlighted matches
53/// - JSON: array of `SearchResult` objects
54///
55/// # Errors
56/// Returns `RecError::Config` if `use_regex` is true and pattern is invalid regex.
57pub fn search_sessions(
58    store: &SessionStore,
59    pattern: &str,
60    use_regex: bool,
61    tag_filter: &[String],
62    json: bool,
63    output: &Output,
64) -> Result<()> {
65    // Compile regex if needed
66    let compiled_regex = if use_regex {
67        Some(
68            Regex::new(pattern)
69                .map_err(|e| RecError::Config(format!("Invalid regex '{pattern}': {e}")))?,
70        )
71    } else {
72        None
73    };
74
75    let ids = store.list()?;
76    let mut results: Vec<SearchResult> = Vec::new();
77
78    for id in &ids {
79        let Ok(session) = store.load(id) else {
80            continue;
81        };
82
83        // Apply tag filter FIRST (cheap check)
84        if !tag_filter.is_empty() {
85            let normalized_filter: Vec<String> =
86                tag_filter.iter().map(|t| normalize_tag(t)).collect();
87            let session_normalized: Vec<String> = session
88                .header
89                .tags
90                .iter()
91                .map(|t| normalize_tag(t))
92                .collect();
93            let has_matching_tag = session_normalized
94                .iter()
95                .any(|st| normalized_filter.iter().any(|ft| ft == st));
96            if !has_matching_tag {
97                continue;
98            }
99        }
100
101        // Check session name
102        let name_matched = matches_pattern(
103            &session.header.name,
104            pattern,
105            use_regex,
106            compiled_regex.as_ref(),
107        );
108
109        // Check tags
110        let tag_matched = session
111            .header
112            .tags
113            .iter()
114            .any(|tag| matches_pattern(tag, pattern, use_regex, compiled_regex.as_ref()));
115
116        // Check commands
117        let mut command_matches: Vec<SearchMatch> = Vec::new();
118        for cmd in &session.commands {
119            if matches_pattern(&cmd.command, pattern, use_regex, compiled_regex.as_ref()) {
120                command_matches.push(SearchMatch {
121                    index: cmd.index,
122                    command: cmd.command.clone(),
123                });
124            }
125        }
126
127        // If anything matched, add to results
128        if name_matched || tag_matched || !command_matches.is_empty() {
129            results.push(SearchResult {
130                session: session.header.name.clone(),
131                id: id.clone(),
132                tags: session.header.tags.clone(),
133                started_at: session.header.started_at,
134                matches: command_matches,
135                name_matched,
136                tag_matched,
137            });
138        }
139    }
140
141    // Sort by started_at descending (most recent first)
142    results.sort_by(|a, b| {
143        b.started_at
144            .partial_cmp(&a.started_at)
145            .unwrap_or(std::cmp::Ordering::Equal)
146    });
147
148    // Output
149    if results.is_empty() {
150        if json {
151            println!("[]");
152        } else {
153            println!("No matches found");
154        }
155        return Ok(());
156    }
157
158    if json {
159        println!(
160            "{}",
161            serde_json::to_string_pretty(&results).unwrap_or_else(|_| "[]".to_string())
162        );
163    } else {
164        let total_matches: usize = results.iter().map(|r| r.matches.len()).sum();
165        let session_count = results.len();
166
167        for (i, result) in results.iter().enumerate() {
168            // Session header
169            let match_count = result.matches.len();
170            let header_text = if output.colors {
171                format!(
172                    "\x1b[1m{}\x1b[0m ({} match{})",
173                    result.session,
174                    match_count,
175                    if match_count == 1 { "" } else { "es" }
176                )
177            } else {
178                format!(
179                    "{} ({} match{})",
180                    result.session,
181                    match_count,
182                    if match_count == 1 { "" } else { "es" }
183                )
184            };
185            println!("{header_text}");
186
187            if result.name_matched {
188                let highlighted = highlight_matches(
189                    &result.session,
190                    pattern,
191                    use_regex,
192                    compiled_regex.as_ref(),
193                    output.colors,
194                );
195                println!("  name: {highlighted}");
196            }
197
198            if result.tag_matched {
199                for tag in &result.tags {
200                    if matches_pattern(tag, pattern, use_regex, compiled_regex.as_ref()) {
201                        let highlighted = highlight_matches(
202                            tag,
203                            pattern,
204                            use_regex,
205                            compiled_regex.as_ref(),
206                            output.colors,
207                        );
208                        println!("  tag: {highlighted}");
209                    }
210                }
211            }
212
213            for m in &result.matches {
214                let highlighted = highlight_matches(
215                    &m.command,
216                    pattern,
217                    use_regex,
218                    compiled_regex.as_ref(),
219                    output.colors,
220                );
221                println!("  {}. {}", m.index + 1, highlighted);
222            }
223
224            if i < results.len() - 1 {
225                println!();
226            }
227        }
228
229        println!();
230        println!("{session_count} session(s), {total_matches} match(es)");
231    }
232
233    Ok(())
234}
235
236/// Check if text matches pattern (substring or regex).
237fn matches_pattern(text: &str, pattern: &str, use_regex: bool, regex: Option<&Regex>) -> bool {
238    if use_regex {
239        regex.is_some_and(|r| r.is_match(text))
240    } else {
241        // Case-insensitive substring search
242        text.to_lowercase().contains(&pattern.to_lowercase())
243    }
244}
245
246/// Highlight pattern matches in text with bold ANSI codes.
247///
248/// - If `!colors`, returns text unchanged.
249/// - For substring: replaces all case-insensitive occurrences with bold.
250/// - For regex: wraps each regex match with bold ANSI codes.
251fn highlight_matches(
252    text: &str,
253    pattern: &str,
254    use_regex: bool,
255    regex: Option<&Regex>,
256    colors: bool,
257) -> String {
258    if !colors {
259        return text.to_string();
260    }
261
262    if use_regex {
263        if let Some(re) = regex {
264            let mut result = String::new();
265            let mut last_end = 0;
266            for m in re.find_iter(text) {
267                result.push_str(&text[last_end..m.start()]);
268                result.push_str("\x1b[1m");
269                result.push_str(m.as_str());
270                result.push_str("\x1b[0m");
271                last_end = m.end();
272            }
273            result.push_str(&text[last_end..]);
274            result
275        } else {
276            text.to_string()
277        }
278    } else {
279        // Case-insensitive substring highlighting
280        let lower_text = text.to_lowercase();
281        let lower_pattern = pattern.to_lowercase();
282        let mut result = String::new();
283        let mut last_end = 0;
284
285        for (start, _) in lower_text.match_indices(&lower_pattern) {
286            result.push_str(&text[last_end..start]);
287            result.push_str("\x1b[1m");
288            result.push_str(&text[start..start + pattern.len()]);
289            result.push_str("\x1b[0m");
290            last_end = start + pattern.len();
291        }
292        result.push_str(&text[last_end..]);
293        result
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::models::{Command, Session, SessionStatus};
301    use crate::storage::{Paths, SessionStore};
302    use std::path::PathBuf;
303    use tempfile::TempDir;
304
305    fn create_test_store(temp_dir: &TempDir) -> SessionStore {
306        let paths = Paths {
307            data_dir: temp_dir.path().join("sessions"),
308            config_dir: temp_dir.path().join("config"),
309            config_file: temp_dir.path().join("config").join("config.toml"),
310            state_dir: temp_dir.path().join("state"),
311        };
312        SessionStore::new(paths)
313    }
314
315    fn create_session_with_commands(name: &str, tags: Vec<String>, commands: &[&str]) -> Session {
316        let mut session = Session::new(name);
317        session.header.tags = tags;
318        for (i, cmd_text) in commands.iter().enumerate() {
319            session.commands.push(Command::new(
320                i as u32,
321                cmd_text.to_string(),
322                PathBuf::from("/tmp"),
323            ));
324        }
325        session.complete(SessionStatus::Completed);
326        session
327    }
328
329    // ── highlight_matches tests ───────────────────────────────────────
330
331    #[test]
332    fn test_highlight_matches_substring() {
333        let result = highlight_matches("echo hello world", "hello", false, None, true);
334        assert_eq!(result, "echo \x1b[1mhello\x1b[0m world");
335    }
336
337    #[test]
338    fn test_highlight_matches_no_color() {
339        let result = highlight_matches("echo hello world", "hello", false, None, false);
340        assert_eq!(result, "echo hello world");
341    }
342
343    #[test]
344    fn test_highlight_matches_regex() {
345        let re = Regex::new("hel+o").unwrap();
346        let result = highlight_matches("echo hello world", "hel+o", true, Some(&re), true);
347        assert_eq!(result, "echo \x1b[1mhello\x1b[0m world");
348    }
349
350    #[test]
351    fn test_highlight_matches_case_insensitive_substring() {
352        let result = highlight_matches("echo Docker build", "docker", false, None, true);
353        assert_eq!(result, "echo \x1b[1mDocker\x1b[0m build");
354    }
355
356    #[test]
357    fn test_highlight_matches_multiple_occurrences() {
358        let result = highlight_matches("echo echo echo", "echo", false, None, true);
359        assert_eq!(
360            result,
361            "\x1b[1mecho\x1b[0m \x1b[1mecho\x1b[0m \x1b[1mecho\x1b[0m"
362        );
363    }
364
365    // ── search tests ──────────────────────────────────────────────────
366
367    #[test]
368    fn test_search_finds_matching_commands() {
369        let temp_dir = TempDir::new().unwrap();
370        let store = create_test_store(&temp_dir);
371
372        let s1 = create_session_with_commands(
373            "deploy-session",
374            vec![],
375            &[
376                "docker build .",
377                "docker push image",
378                "kubectl apply -f deploy.yaml",
379            ],
380        );
381        let s2 =
382            create_session_with_commands("setup-session", vec![], &["npm install", "npm test"]);
383        store.save(&s1).unwrap();
384        store.save(&s2).unwrap();
385
386        let output = Output {
387            colors: false,
388            symbols: crate::models::SymbolMode::Ascii,
389            verbosity: crate::models::Verbosity::Normal,
390            json: false,
391        };
392
393        // Search for "docker" — should find deploy-session with 2 matches
394        let result = search_sessions(&store, "docker", false, &[], false, &output);
395        assert!(result.is_ok());
396    }
397
398    #[test]
399    fn test_search_tag_filter() {
400        let temp_dir = TempDir::new().unwrap();
401        let store = create_test_store(&temp_dir);
402
403        let s1 = create_session_with_commands(
404            "tagged-session",
405            vec!["deploy".to_string()],
406            &["echo hello", "echo world"],
407        );
408        let s2 = create_session_with_commands(
409            "other-session",
410            vec!["rust".to_string()],
411            &["echo hello", "cargo build"],
412        );
413        store.save(&s1).unwrap();
414        store.save(&s2).unwrap();
415
416        let output = Output {
417            colors: false,
418            symbols: crate::models::SymbolMode::Ascii,
419            verbosity: crate::models::Verbosity::Normal,
420            json: false,
421        };
422
423        // Search for "echo" with tag filter "deploy" — should only find tagged-session
424        let result = search_sessions(
425            &store,
426            "echo",
427            false,
428            &["deploy".to_string()],
429            false,
430            &output,
431        );
432        assert!(result.is_ok());
433    }
434
435    #[test]
436    fn test_search_regex_mode() {
437        let temp_dir = TempDir::new().unwrap();
438        let store = create_test_store(&temp_dir);
439
440        let s1 = create_session_with_commands(
441            "regex-test",
442            vec![],
443            &["docker build .", "docker-compose up", "npm install"],
444        );
445        store.save(&s1).unwrap();
446
447        let output = Output {
448            colors: false,
449            symbols: crate::models::SymbolMode::Ascii,
450            verbosity: crate::models::Verbosity::Normal,
451            json: false,
452        };
453
454        // Regex pattern matching "docker" followed by space or hyphen
455        let result = search_sessions(&store, "docker[- ]", true, &[], false, &output);
456        assert!(result.is_ok());
457    }
458
459    #[test]
460    fn test_search_no_matches() {
461        let temp_dir = TempDir::new().unwrap();
462        let store = create_test_store(&temp_dir);
463
464        let s1 = create_session_with_commands("my-session", vec![], &["echo hello"]);
465        store.save(&s1).unwrap();
466
467        let output = Output {
468            colors: false,
469            symbols: crate::models::SymbolMode::Ascii,
470            verbosity: crate::models::Verbosity::Normal,
471            json: false,
472        };
473
474        // Search for something that doesn't exist
475        let result = search_sessions(&store, "zzzznonexistent", false, &[], false, &output);
476        assert!(result.is_ok());
477    }
478
479    #[test]
480    fn test_search_case_insensitive_substring() {
481        let temp_dir = TempDir::new().unwrap();
482        let store = create_test_store(&temp_dir);
483
484        let s1 = create_session_with_commands(
485            "docker-session",
486            vec![],
487            &["Docker build .", "DOCKER push image"],
488        );
489        store.save(&s1).unwrap();
490
491        let output = Output {
492            colors: false,
493            symbols: crate::models::SymbolMode::Ascii,
494            verbosity: crate::models::Verbosity::Normal,
495            json: false,
496        };
497
498        // Search with lowercase "docker" should match "Docker" and "DOCKER"
499        let result = search_sessions(&store, "docker", false, &[], false, &output);
500        assert!(result.is_ok());
501    }
502
503    #[test]
504    fn test_search_invalid_regex() {
505        let temp_dir = TempDir::new().unwrap();
506        let store = create_test_store(&temp_dir);
507
508        let output = Output {
509            colors: false,
510            symbols: crate::models::SymbolMode::Ascii,
511            verbosity: crate::models::Verbosity::Normal,
512            json: false,
513        };
514
515        // Invalid regex should return RecError::Config
516        let result = search_sessions(&store, "[invalid", true, &[], false, &output);
517        assert!(result.is_err());
518        match result {
519            Err(RecError::Config(msg)) => {
520                assert!(msg.contains("Invalid regex"));
521            }
522            _ => panic!("Expected RecError::Config"),
523        }
524    }
525
526    #[test]
527    fn test_search_matches_session_name() {
528        let temp_dir = TempDir::new().unwrap();
529        let store = create_test_store(&temp_dir);
530
531        let s1 = create_session_with_commands("deploy-production", vec![], &["echo unrelated"]);
532        store.save(&s1).unwrap();
533
534        let output = Output {
535            colors: false,
536            symbols: crate::models::SymbolMode::Ascii,
537            verbosity: crate::models::Verbosity::Normal,
538            json: false,
539        };
540
541        // Search for "deploy" should match the session name
542        let result = search_sessions(&store, "deploy", false, &[], false, &output);
543        assert!(result.is_ok());
544    }
545
546    #[test]
547    fn test_search_matches_tags() {
548        let temp_dir = TempDir::new().unwrap();
549        let store = create_test_store(&temp_dir);
550
551        let s1 = create_session_with_commands(
552            "my-session",
553            vec!["kubernetes".to_string(), "production".to_string()],
554            &["echo unrelated"],
555        );
556        store.save(&s1).unwrap();
557
558        let output = Output {
559            colors: false,
560            symbols: crate::models::SymbolMode::Ascii,
561            verbosity: crate::models::Verbosity::Normal,
562            json: false,
563        };
564
565        // Search for "kubernetes" should match the tag
566        let result = search_sessions(&store, "kubernetes", false, &[], false, &output);
567        assert!(result.is_ok());
568    }
569
570    #[test]
571    fn test_search_json_output() {
572        let temp_dir = TempDir::new().unwrap();
573        let store = create_test_store(&temp_dir);
574
575        let s1 =
576            create_session_with_commands("json-session", vec!["test".to_string()], &["echo hello"]);
577        store.save(&s1).unwrap();
578
579        let output = Output {
580            colors: false,
581            symbols: crate::models::SymbolMode::Ascii,
582            verbosity: crate::models::Verbosity::Normal,
583            json: true,
584        };
585
586        // JSON search should not error
587        let result = search_sessions(&store, "hello", false, &[], true, &output);
588        assert!(result.is_ok());
589    }
590
591    #[test]
592    fn test_search_empty_store() {
593        let temp_dir = TempDir::new().unwrap();
594        let store = create_test_store(&temp_dir);
595
596        let output = Output {
597            colors: false,
598            symbols: crate::models::SymbolMode::Ascii,
599            verbosity: crate::models::Verbosity::Normal,
600            json: false,
601        };
602
603        let result = search_sessions(&store, "anything", false, &[], false, &output);
604        assert!(result.is_ok());
605    }
606}