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(serialized_entry_len).sum::<usize>()
433 + blocks.iter().map(serialized_entry_len).sum::<usize>();
434 let mut bytes_returned = 0usize;
435 let mut returned_matches = 0usize;
436 let mut byte_truncated = false;
437 let mut returned_flat = Vec::new();
438 let mut returned_blocks = Vec::new();
439
440 for mut item in flat {
441 let remaining = options.max_bytes.saturating_sub(bytes_returned);
442 let mut item_bytes = serialized_entry_len(&item);
443 if item_bytes > remaining {
444 if !returned_flat.is_empty() || remaining == 0 {
445 byte_truncated = true;
446 break;
447 }
448 truncate_line_to_serialized_size(&mut item, remaining);
449 item_bytes = serialized_entry_len(&item);
450 byte_truncated = true;
451 if item_bytes > remaining {
452 break;
453 }
454 }
455 bytes_returned += item_bytes;
456 returned_matches += 1;
457 returned_flat.push(item);
458 if byte_truncated {
459 break;
460 }
461 }
462
463 for mut block in blocks {
464 let remaining = options.max_bytes.saturating_sub(bytes_returned);
465 let mut block_bytes = serialized_entry_len(&block);
466 if block_bytes > remaining {
467 if !returned_blocks.is_empty() || remaining == 0 {
468 byte_truncated = true;
469 break;
470 }
471 truncate_block_to_serialized_size(&mut block, remaining);
472 block_bytes = serialized_entry_len(&block);
473 byte_truncated = true;
474 if block_bytes > remaining {
475 break;
476 }
477 }
478 bytes_returned += block_bytes;
479 returned_matches += block.match_line_numbers.len();
480 returned_blocks.push(block);
481 if byte_truncated {
482 break;
483 }
484 }
485
486 let next = options.offset.saturating_add(returned_matches);
487 GrepSearchResult {
488 matches: returned_flat,
489 blocks: returned_blocks,
490 total_matches,
491 returned_matches,
492 bytes_returned,
493 bytes_total,
494 next_offset: (next < total_matches).then_some(next),
495 byte_truncated,
496 }
497}
498
499fn serialized_entry_len<T: Serialize>(value: &T) -> usize {
501 serde_json::to_vec(value)
502 .expect("grep result types are always JSON serializable")
503 .len()
504 .saturating_add(1)
505}
506
507fn truncate_line_to_serialized_size(item: &mut GrepMatch, max_bytes: usize) {
508 let original = std::mem::take(&mut item.line);
509 let mut low = 0;
510 let mut high = original.len();
511 while low < high {
512 let mid = low + (high - low).div_ceil(2);
513 item.line = truncate_utf8(&original, mid).to_string();
514 if serialized_entry_len(item) <= max_bytes {
515 low = mid;
516 } else {
517 high = mid - 1;
518 }
519 }
520 item.line = truncate_utf8(&original, low).to_string();
521}
522
523fn truncate_block_to_serialized_size(block: &mut GrepContextBlock, max_bytes: usize) {
524 let originals: Vec<_> = block
525 .lines
526 .iter_mut()
527 .map(|line| std::mem::take(&mut line.line))
528 .collect();
529 for (index, original) in originals.iter().enumerate() {
530 let mut low = 0;
531 let mut high = original.len();
532 while low < high {
533 let mid = low + (high - low).div_ceil(2);
534 block.lines[index].line = truncate_utf8(original, mid).to_string();
535 if serialized_entry_len(block) <= max_bytes {
536 low = mid;
537 } else {
538 high = mid - 1;
539 }
540 }
541 block.lines[index].line = truncate_utf8(original, low).to_string();
542 if low < original.len() {
543 break;
544 }
545 }
546}
547
548fn truncate_utf8(value: &str, max_bytes: usize) -> &str {
549 let mut end = max_bytes.min(value.len());
550 while end > 0 && !value.is_char_boundary(end) {
551 end -= 1;
552 }
553 &value[..end]
554}
555
556#[derive(Debug, Clone, Serialize, Deserialize)]
558#[cfg_attr(feature = "openapi", derive(ToSchema))]
559pub struct GrepResult {
560 pub path: String,
561 pub matches: Vec<GrepMatch>,
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567
568 #[test]
569 fn test_name_from_path() {
570 assert_eq!(FileInfo::name_from_path("/"), "/");
571 assert_eq!(FileInfo::name_from_path("/foo"), "foo");
572 assert_eq!(FileInfo::name_from_path("/foo/bar"), "bar");
573 assert_eq!(FileInfo::name_from_path("/foo/bar/baz.txt"), "baz.txt");
574 }
575
576 #[test]
577 fn test_parent_path() {
578 assert_eq!(FileInfo::parent_path("/"), None);
579 assert_eq!(FileInfo::parent_path("/foo"), Some("/".to_string()));
580 assert_eq!(FileInfo::parent_path("/foo/bar"), Some("/foo".to_string()));
581 assert_eq!(
582 FileInfo::parent_path("/foo/bar/baz"),
583 Some("/foo/bar".to_string())
584 );
585 }
586
587 #[test]
588 fn test_is_text_content() {
589 assert!(SessionFile::is_text_content(b"hello world"));
590 assert!(SessionFile::is_text_content(b"line1\nline2\n"));
591 assert!(!SessionFile::is_text_content(b"hello\0world"));
592 }
593
594 #[test]
595 fn test_encode_content_text() {
596 let (content, encoding) = SessionFile::encode_content(b"hello world");
597 assert_eq!(content, "hello world");
598 assert_eq!(encoding, "text");
599 }
600
601 #[test]
602 fn test_encode_content_binary() {
603 let binary = b"\x89PNG\r\n\x1a\n\0";
605 let (content, encoding) = SessionFile::encode_content(binary);
606 assert_eq!(encoding, "base64");
607 assert!(!content.is_empty());
608 }
609
610 #[test]
611 fn test_decode_content_text() {
612 let decoded = SessionFile::decode_content("hello world", "text").unwrap();
613 assert_eq!(decoded, b"hello world");
614 }
615
616 #[test]
617 fn test_decode_content_base64() {
618 let decoded = SessionFile::decode_content("aGVsbG8=", "base64").unwrap();
619 assert_eq!(decoded, b"hello");
620 }
621
622 #[test]
623 fn test_encode_decode_roundtrip() {
624 let original = b"Test content with special chars: \xc3\xa9\xc3\xa0";
625 let (encoded, encoding) = SessionFile::encode_content(original);
626 let decoded = SessionFile::decode_content(&encoded, &encoding).unwrap();
627 assert_eq!(decoded, original);
628 }
629
630 #[test]
631 fn test_file_info_serialization() {
632 let file_info = FileInfo {
633 id: Uuid::nil(),
634 session_id: Uuid::nil(),
635 path: "/test.txt".to_string(),
636 name: "test.txt".to_string(),
637 is_directory: false,
638 is_readonly: false,
639 size_bytes: 100,
640 created_at: DateTime::default(),
641 updated_at: DateTime::default(),
642 };
643
644 let json = serde_json::to_string(&file_info).unwrap();
645 assert!(json.contains("\"path\":\"/test.txt\""));
646 assert!(json.contains("\"is_directory\":false"));
647 }
648
649 #[test]
650 fn test_grep_result_serialization() {
651 let result = GrepResult {
652 path: "/test.txt".to_string(),
653 matches: vec![GrepMatch {
654 path: "/test.txt".to_string(),
655 line_number: 1,
656 line: "hello world".to_string(),
657 }],
658 };
659
660 let json = serde_json::to_string(&result).unwrap();
661 assert!(json.contains("\"line_number\":1"));
662 assert!(json.contains("\"line\":\"hello world\""));
663 }
664
665 #[test]
666 fn merge_context_results_applies_one_match_window_without_duplicate_lines() {
667 let block = |path: &str, start: usize, matches: &[usize]| GrepContextBlock {
668 path: path.to_string(),
669 start_line: start,
670 end_line: start + 2,
671 match_line_numbers: matches.to_vec(),
672 lines: (start..=start + 2)
673 .map(|line_number| GrepContextLine {
674 line_number,
675 line: format!("line {line_number}"),
676 is_match: matches.contains(&line_number),
677 })
678 .collect(),
679 };
680 let result = |blocks| GrepSearchResult {
681 matches: Vec::new(),
682 blocks,
683 total_matches: 0,
684 returned_matches: 0,
685 bytes_returned: 0,
686 bytes_total: 0,
687 next_offset: None,
688 byte_truncated: false,
689 };
690 let options = GrepOptions {
691 before_context: 1,
692 after_context: 1,
693 offset: 1,
694 limit: 2,
695 ..GrepOptions::default()
696 };
697
698 let merged = merge_grep_search_results(
699 vec![
700 result(vec![block("/a.txt", 1, &[2]), block("/a.txt", 3, &[4])]),
701 result(vec![block("/b.txt", 4, &[5])]),
702 ],
703 &options,
704 );
705
706 assert_eq!(merged.total_matches, 3);
707 assert_eq!(merged.returned_matches, 2);
708 assert_eq!(merged.next_offset, None);
709 assert_eq!(merged.blocks.len(), 2);
710 assert_eq!(merged.blocks[0].match_line_numbers, vec![4]);
711 assert_eq!(merged.blocks[1].match_line_numbers, vec![5]);
712 assert_eq!(
713 merged.blocks[0]
714 .lines
715 .iter()
716 .map(|line| line.line_number)
717 .collect::<Vec<_>>(),
718 vec![3, 4, 5]
719 );
720 }
721
722 #[test]
723 fn contextual_grep_budgets_serialized_structure() {
724 let blocks = (0..1_000)
725 .map(|index| GrepContextBlock {
726 path: format!("/sparse/{index}.txt"),
727 start_line: 1,
728 end_line: 41,
729 match_line_numbers: vec![21],
730 lines: (1..=41)
731 .map(|line_number| GrepContextLine {
732 line_number,
733 line: (if line_number == 21 { "x" } else { "" }).to_string(),
734 is_match: line_number == 21,
735 })
736 .collect(),
737 })
738 .collect();
739 let result = apply_grep_byte_budget(Vec::new(), blocks, 1_000, &GrepOptions::default());
740 let serialized_blocks = serde_json::to_vec(&result.blocks).unwrap();
741
742 assert!(result.byte_truncated);
743 assert!(result.returned_matches < 1_000);
744 assert!(result.bytes_total > GREP_MAX_RETURN_BYTES);
745 assert!(result.bytes_returned <= GREP_MAX_RETURN_BYTES);
746 assert!(serialized_blocks.len() <= GREP_MAX_RETURN_BYTES);
747 }
748}