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 text_heuristic_samples_only_first_eight_kibibytes() {
589 for bytes in [b"".as_slice(), b"hello world", b"line1\nline2\n"] {
590 assert!(SessionFile::is_text_content(bytes));
591 }
592 let mut bytes = vec![b'a'; 8193];
593 bytes[8192] = 0;
594 assert!(SessionFile::is_text_content(&bytes));
595 bytes[8191] = 0;
596 assert!(!SessionFile::is_text_content(&bytes));
597 assert!(!SessionFile::is_text_content(b"hello\0world"));
598 }
599
600 #[test]
601 fn content_encoding_preserves_exact_text_and_binary_wire_values() {
602 for (input, content, encoding) in [
603 (b"".as_slice(), "", "text"),
604 (b"hello world".as_slice(), "hello world", "text"),
605 ("éà".as_bytes(), "éà", "text"),
606 (b"a\0b".as_slice(), "YQBi", "base64"),
607 (b"\xff\xfe".as_slice(), "//4=", "base64"),
608 ] {
609 let actual = SessionFile::encode_content(input);
610 assert_eq!(actual, (content.to_string(), encoding.to_string()));
611 assert_eq!(
612 SessionFile::decode_content(&actual.0, &actual.1).unwrap(),
613 input
614 );
615 }
616 }
617
618 #[test]
619 fn content_decoding_preserves_text_and_rejects_invalid_base64() {
620 assert_eq!(
621 SessionFile::decode_content("literal ! é", "text").unwrap(),
622 "literal ! é".as_bytes()
623 );
624 assert_eq!(
625 SessionFile::decode_content("aGVsbG8=", "base64").unwrap(),
626 b"hello"
627 );
628 assert_eq!(
629 SessionFile::decode_content("YQBi", "base64").unwrap(),
630 b"a\0b"
631 );
632 for malformed in ["!", "YQ=", "===="] {
633 assert!(
634 SessionFile::decode_content(malformed, "base64").is_err(),
635 "{malformed}"
636 );
637 }
638 }
639
640 #[test]
641 fn merge_context_results_applies_one_match_window_without_duplicate_lines() {
642 let block = |path: &str, start: usize, matches: &[usize]| GrepContextBlock {
643 path: path.to_string(),
644 start_line: start,
645 end_line: start + 2,
646 match_line_numbers: matches.to_vec(),
647 lines: (start..=start + 2)
648 .map(|line_number| GrepContextLine {
649 line_number,
650 line: format!("line {line_number}"),
651 is_match: matches.contains(&line_number),
652 })
653 .collect(),
654 };
655 let result = |blocks| GrepSearchResult {
656 matches: Vec::new(),
657 blocks,
658 total_matches: 0,
659 returned_matches: 0,
660 bytes_returned: 0,
661 bytes_total: 0,
662 next_offset: None,
663 byte_truncated: false,
664 };
665 let options = GrepOptions {
666 before_context: 1,
667 after_context: 1,
668 offset: 1,
669 limit: 2,
670 ..GrepOptions::default()
671 };
672
673 let merged = merge_grep_search_results(
674 vec![
675 result(vec![block("/b.txt", 4, &[5])]),
676 result(vec![
677 block("/a.txt", 3, &[4]),
678 block("/a.txt", 1, &[2]),
679 block("/a.txt", 3, &[4]),
680 ]),
681 ],
682 &options,
683 );
684
685 assert_eq!(merged.total_matches, 3);
686 assert_eq!(merged.returned_matches, 2);
687 assert_eq!(merged.next_offset, None);
688 assert_eq!(merged.blocks.len(), 2);
689 assert_eq!(merged.blocks[0].match_line_numbers, vec![4]);
690 assert_eq!(merged.blocks[1].match_line_numbers, vec![5]);
691 assert_eq!(merged.blocks[0].path, "/a.txt");
692 assert_eq!(merged.blocks[1].path, "/b.txt");
693 assert_eq!(
694 (merged.blocks[0].start_line, merged.blocks[0].end_line),
695 (3, 5)
696 );
697 assert_eq!(
698 (merged.blocks[1].start_line, merged.blocks[1].end_line),
699 (4, 6)
700 );
701 assert!(!merged.byte_truncated);
702 assert_eq!(
703 merged.blocks[0]
704 .lines
705 .iter()
706 .map(|line| (line.line_number, line.line.as_str(), line.is_match))
707 .collect::<Vec<_>>(),
708 [
709 (3, "line 3", false),
710 (4, "line 4", true),
711 (5, "line 5", false)
712 ]
713 );
714 assert_eq!(
715 merged.blocks[1]
716 .lines
717 .iter()
718 .map(|line| (line.line_number, line.line.as_str(), line.is_match))
719 .collect::<Vec<_>>(),
720 [
721 (4, "line 4", false),
722 (5, "line 5", true),
723 (6, "line 6", false)
724 ]
725 );
726 assert_eq!(
727 merged.blocks[0]
728 .lines
729 .iter()
730 .map(|line| line.line_number)
731 .collect::<Vec<_>>(),
732 vec![3, 4, 5]
733 );
734 }
735
736 #[test]
737 fn contextual_grep_budgets_serialized_structure() {
738 let blocks = (0..40)
739 .map(|index| GrepContextBlock {
740 path: format!("/sparse/{index}.txt"),
741 start_line: 1,
742 end_line: 41,
743 match_line_numbers: vec![21],
744 lines: (1..=41)
745 .map(|line_number| GrepContextLine {
746 line_number,
747 line: (if line_number == 21 { "x" } else { "" }).to_string(),
748 is_match: line_number == 21,
749 })
750 .collect(),
751 })
752 .collect();
753 let result = apply_grep_byte_budget(Vec::new(), blocks, 40, &GrepOptions::default());
754 let serialized_blocks = serde_json::to_vec(&result.blocks).unwrap();
755
756 assert!(result.byte_truncated);
757 assert!(result.returned_matches > 0 && result.returned_matches < 40);
758 assert!(result.bytes_total > 65_536);
759 assert!(result.bytes_returned <= 65_536);
760 assert!(serialized_blocks.len() <= 65_536);
761 }
762 #[test]
763 fn flat_grep_pagination_is_global_sorted_and_handles_empty_windows() {
764 let regex = regex::Regex::new("^hit").unwrap();
765 let files = vec![
766 ("/b".into(), "no\nhit b".into()),
767 ("/a".into(), "hit a1\nnot hit\nhit a3".into()),
768 ];
769 for (offset, limit, expected, next) in [
770 (
771 0,
772 2,
773 vec![("/a", 1, "hit a1"), ("/a", 3, "hit a3")],
774 Some(2),
775 ),
776 (1, 1, vec![("/a", 3, "hit a3")], Some(2)),
777 (2, 2, vec![("/b", 2, "hit b")], None),
778 (3, 1, vec![], None),
779 (usize::MAX, 2, vec![], None),
780 (0, 0, vec![], Some(0)),
781 ] {
782 let options = GrepOptions {
783 offset,
784 limit,
785 ..Default::default()
786 };
787 let built = build_grep_search_result(files.clone(), ®ex, &options);
788 let unsorted = vec![
789 GrepMatch {
790 path: "/b".into(),
791 line_number: 2,
792 line: "hit b".into(),
793 },
794 GrepMatch {
795 path: "/a".into(),
796 line_number: 3,
797 line: "hit a3".into(),
798 },
799 GrepMatch {
800 path: "/a".into(),
801 line_number: 1,
802 line: "hit a1".into(),
803 },
804 ];
805 let bounded = bound_grep_matches(unsorted, &options);
806 for result in [built, bounded] {
807 assert_eq!(
808 result
809 .matches
810 .iter()
811 .map(|hit| (hit.path.as_str(), hit.line_number, hit.line.as_str()))
812 .collect::<Vec<_>>(),
813 expected,
814 "offset={offset} limit={limit}"
815 );
816 assert_eq!(result.total_matches, 3);
817 assert_eq!(result.returned_matches, expected.len());
818 assert_eq!(result.next_offset, next);
819 assert!(!result.byte_truncated);
820 assert!(result.blocks.is_empty());
821 }
822 }
823 }
824
825 #[test]
826 fn context_builder_merges_adjacent_windows_and_marks_only_selected_matches() {
827 let result = build_grep_search_result(
828 vec![("/a".into(), "hit first\ncontext\nhit second\nafter".into())],
829 ®ex::Regex::new("^hit").unwrap(),
830 &GrepOptions {
831 before_context: 1,
832 after_context: 1,
833 ..Default::default()
834 },
835 );
836 assert_eq!(result.total_matches, 2);
837 assert_eq!(result.returned_matches, 2);
838 assert_eq!(result.next_offset, None);
839 assert_eq!(result.blocks.len(), 1);
840 let block = &result.blocks[0];
841 assert_eq!(block.path, "/a");
842 assert_eq!((block.start_line, block.end_line), (1, 4));
843 assert_eq!(block.match_line_numbers, [1, 3]);
844 assert_eq!(
845 block
846 .lines
847 .iter()
848 .map(|line| (line.line_number, line.line.as_str(), line.is_match))
849 .collect::<Vec<_>>(),
850 [
851 (1, "hit first", true),
852 (2, "context", false),
853 (3, "hit second", true),
854 (4, "after", false)
855 ]
856 );
857 let paged = build_grep_search_result(
858 vec![("/a".into(), "hit first\ncontext\nhit second\nafter".into())],
859 ®ex::Regex::new("^hit").unwrap(),
860 &GrepOptions {
861 before_context: 2,
862 after_context: 1,
863 offset: 1,
864 limit: 1,
865 ..Default::default()
866 },
867 );
868 assert_eq!(paged.blocks[0].match_line_numbers, [3]);
869 assert!(!paged.blocks[0].lines[0].is_match); assert_eq!(paged.returned_matches, 1);
871 }
872
873 #[test]
874 fn flat_byte_budget_preserves_utf8_and_json_escaping_at_exact_entry_boundary() {
875 let input = GrepMatch {
876 path: "/a".into(),
877 line_number: 1,
878 line: "é\"x\n😀".into(),
879 };
880 let expected = GrepMatch {
881 line: "é".into(),
882 ..input.clone()
883 };
884 let budget = br#"{"path":"/a","line_number":1,"line":""}"#.len() + "é".len() + 1;
886 let result = bound_grep_matches(
887 vec![
888 input.clone(),
889 GrepMatch {
890 path: "/b".into(),
891 line_number: 2,
892 line: "next".into(),
893 },
894 ],
895 &GrepOptions {
896 max_bytes: budget,
897 ..Default::default()
898 },
899 );
900 assert_eq!(
901 serde_json::to_value(&result.matches).unwrap(),
902 serde_json::json!([expected])
903 );
904 assert_eq!(result.bytes_returned, budget);
905 assert!(result.bytes_total > budget);
906 assert!(result.byte_truncated);
907 assert_eq!(result.total_matches, 2);
908 assert_eq!(result.returned_matches, 1);
909 assert_eq!(result.next_offset, Some(1));
910 for max_bytes in [0, 1] {
911 let empty = bound_grep_matches(
912 vec![input.clone()],
913 &GrepOptions {
914 max_bytes,
915 ..Default::default()
916 },
917 );
918 assert!(empty.matches.is_empty());
919 assert_eq!(empty.bytes_returned, 0);
920 assert!(empty.byte_truncated);
921 assert_eq!(empty.next_offset, Some(0));
922 }
923 }
924}