1use 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#[derive(Debug, Clone, Serialize)]
16pub struct SearchMatch {
17 pub index: u32,
19 pub command: String,
21}
22
23#[derive(Debug, Clone, Serialize)]
25pub struct SearchResult {
26 pub session: String,
28 pub id: String,
30 pub tags: Vec<String>,
32 pub started_at: f64,
34 pub matches: Vec<SearchMatch>,
36 pub name_matched: bool,
38 pub tag_matched: bool,
40}
41
42pub 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 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 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 let name_matched = matches_pattern(
103 &session.header.name,
104 pattern,
105 use_regex,
106 compiled_regex.as_ref(),
107 );
108
109 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 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 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 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 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 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
236fn 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 text.to_lowercase().contains(&pattern.to_lowercase())
243 }
244}
245
246fn 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 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 #[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 #[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 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 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 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 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 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 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 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 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 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}