1use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, BTreeSet};
10use uuid::Uuid;
11
12pub const GREP_MAX_CONTEXT_LINES: usize = 20;
14pub const GREP_MAX_RETURN_BYTES: usize = 64 * 1024;
16
17#[cfg(feature = "openapi")]
18use utoipa::ToSchema;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(ToSchema))]
23pub struct FileInfo {
24 #[cfg_attr(
26 feature = "openapi",
27 schema(example = "550e8400-e29b-41d4-a716-446655440000")
28 )]
29 pub id: Uuid,
30 #[cfg_attr(
32 feature = "openapi",
33 schema(example = "01933b5a-0000-7000-8000-000000000001")
34 )]
35 pub session_id: Uuid,
36 #[cfg_attr(feature = "openapi", schema(example = "/notes.md"))]
38 pub path: String,
39 #[cfg_attr(feature = "openapi", schema(example = "notes.md"))]
41 pub name: String,
42 #[cfg_attr(feature = "openapi", schema(example = false))]
44 pub is_directory: bool,
45 #[cfg_attr(feature = "openapi", schema(example = false))]
47 pub is_readonly: bool,
48 #[cfg_attr(feature = "openapi", schema(example = 4096))]
50 pub size_bytes: i64,
51 #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:14:00Z"))]
53 pub created_at: DateTime<Utc>,
54 #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:15:30Z"))]
56 pub updated_at: DateTime<Utc>,
57}
58
59impl FileInfo {
60 pub fn name_from_path(path: &str) -> String {
62 if path == "/" {
63 "/".to_string()
64 } else {
65 path.rsplit('/').next().unwrap_or(path).to_string()
66 }
67 }
68
69 pub fn parent_path(path: &str) -> Option<String> {
71 if path == "/" {
72 None
73 } else {
74 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("/");
75 Some(if parent.is_empty() { "/" } else { parent }.to_string())
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82#[cfg_attr(feature = "openapi", derive(ToSchema))]
83pub struct SessionFile {
84 pub id: Uuid,
86 pub session_id: Uuid,
88 pub path: String,
90 pub name: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub content: Option<String>,
95 #[serde(default = "default_encoding")]
97 pub encoding: String,
98 pub is_directory: bool,
100 pub is_readonly: bool,
102 pub size_bytes: i64,
104 pub created_at: DateTime<Utc>,
106 pub updated_at: DateTime<Utc>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112#[cfg_attr(feature = "openapi", derive(ToSchema))]
113pub struct InitialFile {
114 pub path: String,
116 pub content: String,
118 #[serde(default = "default_encoding")]
120 pub encoding: String,
121 #[serde(default)]
123 pub is_readonly: bool,
124}
125
126fn default_encoding() -> String {
127 "text".to_string()
128}
129
130impl SessionFile {
131 pub fn is_text_content(bytes: &[u8]) -> bool {
133 let check_len = bytes.len().min(8192);
135 !bytes[..check_len].contains(&0)
136 }
137
138 pub fn encode_content(bytes: &[u8]) -> (String, String) {
140 if Self::is_text_content(bytes) {
141 match String::from_utf8(bytes.to_vec()) {
142 Ok(text) => (text, "text".to_string()),
143 Err(_) => (BASE64.encode(bytes), "base64".to_string()),
144 }
145 } else {
146 (BASE64.encode(bytes), "base64".to_string())
147 }
148 }
149
150 pub fn decode_content(content: &str, encoding: &str) -> Result<Vec<u8>, base64::DecodeError> {
152 match encoding {
153 "base64" => BASE64.decode(content),
154 _ => Ok(content.as_bytes().to_vec()),
155 }
156 }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161#[cfg_attr(feature = "openapi", derive(ToSchema))]
162pub struct FileStat {
163 pub path: String,
165 pub name: String,
167 pub is_directory: bool,
169 pub is_readonly: bool,
171 pub size_bytes: i64,
173 pub created_at: DateTime<Utc>,
175 pub updated_at: DateTime<Utc>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181#[cfg_attr(feature = "openapi", derive(ToSchema))]
182pub struct GrepMatch {
183 pub path: String,
184 pub line_number: usize,
185 pub line: String,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct GrepOptions {
191 pub path_pattern: Option<String>,
192 pub before_context: usize,
193 pub after_context: usize,
194 pub offset: usize,
195 pub limit: usize,
196 pub max_bytes: usize,
197}
198
199impl Default for GrepOptions {
200 fn default() -> Self {
201 Self {
202 path_pattern: None,
203 before_context: 0,
204 after_context: 0,
205 offset: 0,
206 limit: usize::MAX,
207 max_bytes: GREP_MAX_RETURN_BYTES,
208 }
209 }
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214#[cfg_attr(feature = "openapi", derive(ToSchema))]
215pub struct GrepContextLine {
216 pub line_number: usize,
217 pub line: String,
218 pub is_match: bool,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
223#[cfg_attr(feature = "openapi", derive(ToSchema))]
224pub struct GrepContextBlock {
225 pub path: String,
226 pub start_line: usize,
227 pub end_line: usize,
228 pub match_line_numbers: Vec<usize>,
229 pub lines: Vec<GrepContextLine>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
234#[cfg_attr(feature = "openapi", derive(ToSchema))]
235pub struct GrepSearchResult {
236 pub matches: Vec<GrepMatch>,
238 pub blocks: Vec<GrepContextBlock>,
240 pub total_matches: usize,
241 pub returned_matches: usize,
242 pub bytes_returned: usize,
243 pub bytes_total: usize,
244 pub next_offset: Option<usize>,
245 pub byte_truncated: bool,
246}
247
248pub fn build_grep_search_result(
251 mut files: Vec<(String, String)>,
252 regex: ®ex::Regex,
253 options: &GrepOptions,
254) -> GrepSearchResult {
255 files.sort_by(|a, b| a.0.cmp(&b.0));
256
257 let mut total_matches = 0usize;
258 let mut remaining_offset = options.offset;
259 let mut remaining_limit = options.limit;
260 let mut flat = Vec::new();
261 let mut blocks = Vec::new();
262
263 for (path, text) in files {
264 let lines: Vec<&str> = text.lines().collect();
265 let file_matches: Vec<usize> = lines
266 .iter()
267 .enumerate()
268 .filter_map(|(index, line)| regex.is_match(line).then_some(index))
269 .collect();
270 total_matches = total_matches.saturating_add(file_matches.len());
271
272 let skip = remaining_offset.min(file_matches.len());
273 remaining_offset -= skip;
274 let selected: Vec<usize> = file_matches
275 .into_iter()
276 .skip(skip)
277 .take(remaining_limit)
278 .collect();
279 remaining_limit = remaining_limit.saturating_sub(selected.len());
280
281 if options.before_context == 0 && options.after_context == 0 {
282 flat.extend(selected.into_iter().map(|index| GrepMatch {
283 path: path.clone(),
284 line_number: index + 1,
285 line: lines[index].to_string(),
286 }));
287 continue;
288 }
289
290 let mut ranges: Vec<(usize, usize, Vec<usize>)> = Vec::new();
291 for index in selected {
292 let start = index.saturating_sub(options.before_context);
293 let end = index
294 .saturating_add(options.after_context)
295 .min(lines.len().saturating_sub(1));
296 if let Some((_, previous_end, match_indexes)) = ranges.last_mut()
297 && start <= previous_end.saturating_add(1)
298 {
299 *previous_end = (*previous_end).max(end);
300 match_indexes.push(index);
301 } else {
302 ranges.push((start, end, vec![index]));
303 }
304 }
305
306 for (start, end, match_indexes) in ranges {
307 let context_lines = (start..=end)
308 .map(|index| GrepContextLine {
309 line_number: index + 1,
310 line: lines[index].to_string(),
311 is_match: match_indexes.binary_search(&index).is_ok(),
312 })
313 .collect();
314 blocks.push(GrepContextBlock {
315 path: path.clone(),
316 start_line: start + 1,
317 end_line: end + 1,
318 match_line_numbers: match_indexes.into_iter().map(|index| index + 1).collect(),
319 lines: context_lines,
320 });
321 }
322 }
323
324 apply_grep_byte_budget(flat, blocks, total_matches, options)
325}
326
327pub fn bound_grep_matches(mut matches: Vec<GrepMatch>, options: &GrepOptions) -> GrepSearchResult {
329 matches.sort_by(|a, b| {
330 a.path
331 .cmp(&b.path)
332 .then(a.line_number.cmp(&b.line_number))
333 .then(a.line.cmp(&b.line))
334 });
335 let total_matches = matches.len();
336 let selected = matches
337 .into_iter()
338 .skip(options.offset)
339 .take(options.limit)
340 .collect();
341 apply_grep_byte_budget(selected, Vec::new(), total_matches, options)
342}
343
344pub fn merge_grep_search_results(
346 results: Vec<GrepSearchResult>,
347 options: &GrepOptions,
348) -> GrepSearchResult {
349 if options.before_context == 0 && options.after_context == 0 {
350 return bound_grep_matches(
351 results
352 .into_iter()
353 .flat_map(|result| result.matches)
354 .collect(),
355 options,
356 );
357 }
358
359 let mut lines_by_path: BTreeMap<String, BTreeMap<usize, String>> = BTreeMap::new();
360 let mut matches_by_path: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
361 for result in results {
362 for block in result.blocks {
363 let path_lines = lines_by_path.entry(block.path.clone()).or_default();
364 for line in block.lines {
365 path_lines.entry(line.line_number).or_insert(line.line);
366 }
367 matches_by_path
368 .entry(block.path)
369 .or_default()
370 .extend(block.match_line_numbers);
371 }
372 }
373
374 let total_matches = matches_by_path.values().map(BTreeSet::len).sum();
375 let selected: Vec<(String, usize)> = matches_by_path
376 .iter()
377 .flat_map(|(path, lines)| lines.iter().map(move |line| (path.clone(), *line)))
378 .skip(options.offset)
379 .take(options.limit)
380 .collect();
381 let mut selected_by_path: BTreeMap<String, Vec<usize>> = BTreeMap::new();
382 for (path, line) in selected {
383 selected_by_path.entry(path).or_default().push(line);
384 }
385
386 let mut blocks = Vec::new();
387 for (path, match_lines) in selected_by_path {
388 let available = &lines_by_path[&path];
389 let mut ranges: Vec<(usize, usize, Vec<usize>)> = Vec::new();
390 for line in match_lines {
391 let start = line.saturating_sub(options.before_context).max(1);
392 let end = line.saturating_add(options.after_context);
393 if let Some((_, previous_end, matches)) = ranges.last_mut()
394 && start <= previous_end.saturating_add(1)
395 {
396 *previous_end = (*previous_end).max(end);
397 matches.push(line);
398 } else {
399 ranges.push((start, end, vec![line]));
400 }
401 }
402 for (start, end, match_line_numbers) in ranges {
403 let selected_set: BTreeSet<_> = match_line_numbers.iter().copied().collect();
404 let lines: Vec<_> = available
405 .range(start..=end)
406 .map(|(line_number, line)| GrepContextLine {
407 line_number: *line_number,
408 line: line.clone(),
409 is_match: selected_set.contains(line_number),
410 })
411 .collect();
412 if let (Some(first), Some(last)) = (lines.first(), lines.last()) {
413 blocks.push(GrepContextBlock {
414 path: path.clone(),
415 start_line: first.line_number,
416 end_line: last.line_number,
417 match_line_numbers,
418 lines,
419 });
420 }
421 }
422 }
423 apply_grep_byte_budget(Vec::new(), blocks, total_matches, options)
424}
425
426fn apply_grep_byte_budget(
427 flat: Vec<GrepMatch>,
428 blocks: Vec<GrepContextBlock>,
429 total_matches: usize,
430 options: &GrepOptions,
431) -> GrepSearchResult {
432 let bytes_total = flat.iter().map(|item| item.line.len()).sum::<usize>()
433 + blocks
434 .iter()
435 .flat_map(|block| &block.lines)
436 .map(|item| item.line.len())
437 .sum::<usize>();
438 let mut bytes_returned = 0usize;
439 let mut returned_matches = 0usize;
440 let mut byte_truncated = false;
441 let mut returned_flat = Vec::new();
442 let mut returned_blocks = Vec::new();
443
444 for mut item in flat {
445 let remaining = options.max_bytes.saturating_sub(bytes_returned);
446 if item.line.len() > remaining {
447 if !returned_flat.is_empty() || remaining == 0 {
448 byte_truncated = true;
449 break;
450 }
451 item.line = truncate_utf8(&item.line, remaining).to_string();
452 byte_truncated = true;
453 }
454 bytes_returned += item.line.len();
455 returned_matches += 1;
456 returned_flat.push(item);
457 if byte_truncated {
458 break;
459 }
460 }
461
462 for mut block in blocks {
463 let block_bytes: usize = block.lines.iter().map(|item| item.line.len()).sum();
464 let remaining = options.max_bytes.saturating_sub(bytes_returned);
465 if block_bytes > remaining {
466 if !returned_blocks.is_empty() || remaining == 0 {
467 byte_truncated = true;
468 break;
469 }
470 let mut left = remaining;
471 for line in &mut block.lines {
472 if line.line.len() > left {
473 line.line = truncate_utf8(&line.line, left).to_string();
474 left = 0;
475 } else {
476 left -= line.line.len();
477 }
478 }
479 byte_truncated = true;
480 }
481 bytes_returned += block
482 .lines
483 .iter()
484 .map(|item| item.line.len())
485 .sum::<usize>();
486 returned_matches += block.match_line_numbers.len();
487 returned_blocks.push(block);
488 if byte_truncated {
489 break;
490 }
491 }
492
493 let next = options.offset.saturating_add(returned_matches);
494 GrepSearchResult {
495 matches: returned_flat,
496 blocks: returned_blocks,
497 total_matches,
498 returned_matches,
499 bytes_returned,
500 bytes_total,
501 next_offset: (next < total_matches).then_some(next),
502 byte_truncated,
503 }
504}
505
506fn truncate_utf8(value: &str, max_bytes: usize) -> &str {
507 let mut end = max_bytes.min(value.len());
508 while end > 0 && !value.is_char_boundary(end) {
509 end -= 1;
510 }
511 &value[..end]
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
516#[cfg_attr(feature = "openapi", derive(ToSchema))]
517pub struct GrepResult {
518 pub path: String,
519 pub matches: Vec<GrepMatch>,
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn test_name_from_path() {
528 assert_eq!(FileInfo::name_from_path("/"), "/");
529 assert_eq!(FileInfo::name_from_path("/foo"), "foo");
530 assert_eq!(FileInfo::name_from_path("/foo/bar"), "bar");
531 assert_eq!(FileInfo::name_from_path("/foo/bar/baz.txt"), "baz.txt");
532 }
533
534 #[test]
535 fn test_parent_path() {
536 assert_eq!(FileInfo::parent_path("/"), None);
537 assert_eq!(FileInfo::parent_path("/foo"), Some("/".to_string()));
538 assert_eq!(FileInfo::parent_path("/foo/bar"), Some("/foo".to_string()));
539 assert_eq!(
540 FileInfo::parent_path("/foo/bar/baz"),
541 Some("/foo/bar".to_string())
542 );
543 }
544
545 #[test]
546 fn test_is_text_content() {
547 assert!(SessionFile::is_text_content(b"hello world"));
548 assert!(SessionFile::is_text_content(b"line1\nline2\n"));
549 assert!(!SessionFile::is_text_content(b"hello\0world"));
550 }
551
552 #[test]
553 fn test_encode_content_text() {
554 let (content, encoding) = SessionFile::encode_content(b"hello world");
555 assert_eq!(content, "hello world");
556 assert_eq!(encoding, "text");
557 }
558
559 #[test]
560 fn test_encode_content_binary() {
561 let binary = b"\x89PNG\r\n\x1a\n\0";
563 let (content, encoding) = SessionFile::encode_content(binary);
564 assert_eq!(encoding, "base64");
565 assert!(!content.is_empty());
566 }
567
568 #[test]
569 fn test_decode_content_text() {
570 let decoded = SessionFile::decode_content("hello world", "text").unwrap();
571 assert_eq!(decoded, b"hello world");
572 }
573
574 #[test]
575 fn test_decode_content_base64() {
576 let decoded = SessionFile::decode_content("aGVsbG8=", "base64").unwrap();
577 assert_eq!(decoded, b"hello");
578 }
579
580 #[test]
581 fn test_encode_decode_roundtrip() {
582 let original = b"Test content with special chars: \xc3\xa9\xc3\xa0";
583 let (encoded, encoding) = SessionFile::encode_content(original);
584 let decoded = SessionFile::decode_content(&encoded, &encoding).unwrap();
585 assert_eq!(decoded, original);
586 }
587
588 #[test]
589 fn test_file_info_serialization() {
590 let file_info = FileInfo {
591 id: Uuid::nil(),
592 session_id: Uuid::nil(),
593 path: "/test.txt".to_string(),
594 name: "test.txt".to_string(),
595 is_directory: false,
596 is_readonly: false,
597 size_bytes: 100,
598 created_at: DateTime::default(),
599 updated_at: DateTime::default(),
600 };
601
602 let json = serde_json::to_string(&file_info).unwrap();
603 assert!(json.contains("\"path\":\"/test.txt\""));
604 assert!(json.contains("\"is_directory\":false"));
605 }
606
607 #[test]
608 fn test_grep_result_serialization() {
609 let result = GrepResult {
610 path: "/test.txt".to_string(),
611 matches: vec![GrepMatch {
612 path: "/test.txt".to_string(),
613 line_number: 1,
614 line: "hello world".to_string(),
615 }],
616 };
617
618 let json = serde_json::to_string(&result).unwrap();
619 assert!(json.contains("\"line_number\":1"));
620 assert!(json.contains("\"line\":\"hello world\""));
621 }
622
623 #[test]
624 fn merge_context_results_applies_one_match_window_without_duplicate_lines() {
625 let block = |path: &str, start: usize, matches: &[usize]| GrepContextBlock {
626 path: path.to_string(),
627 start_line: start,
628 end_line: start + 2,
629 match_line_numbers: matches.to_vec(),
630 lines: (start..=start + 2)
631 .map(|line_number| GrepContextLine {
632 line_number,
633 line: format!("line {line_number}"),
634 is_match: matches.contains(&line_number),
635 })
636 .collect(),
637 };
638 let result = |blocks| GrepSearchResult {
639 matches: Vec::new(),
640 blocks,
641 total_matches: 0,
642 returned_matches: 0,
643 bytes_returned: 0,
644 bytes_total: 0,
645 next_offset: None,
646 byte_truncated: false,
647 };
648 let options = GrepOptions {
649 before_context: 1,
650 after_context: 1,
651 offset: 1,
652 limit: 2,
653 ..GrepOptions::default()
654 };
655
656 let merged = merge_grep_search_results(
657 vec![
658 result(vec![block("/a.txt", 1, &[2]), block("/a.txt", 3, &[4])]),
659 result(vec![block("/b.txt", 4, &[5])]),
660 ],
661 &options,
662 );
663
664 assert_eq!(merged.total_matches, 3);
665 assert_eq!(merged.returned_matches, 2);
666 assert_eq!(merged.next_offset, None);
667 assert_eq!(merged.blocks.len(), 2);
668 assert_eq!(merged.blocks[0].match_line_numbers, vec![4]);
669 assert_eq!(merged.blocks[1].match_line_numbers, vec![5]);
670 assert_eq!(
671 merged.blocks[0]
672 .lines
673 .iter()
674 .map(|line| line.line_number)
675 .collect::<Vec<_>>(),
676 vec![3, 4, 5]
677 );
678 }
679}