Skip to main content

everruns_core/
session_file.rs

1// Session File domain types (Virtual Filesystem)
2//
3// These types represent files and directories stored within a session's
4// virtual filesystem. Each session has its own isolated filesystem.
5
6use 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
12/// Maximum number of context lines accepted on either side of a grep match.
13pub const GREP_MAX_CONTEXT_LINES: usize = 20;
14/// Maximum serialized entry bytes returned by one grep request.
15pub const GREP_MAX_RETURN_BYTES: usize = 64 * 1024;
16
17#[cfg(feature = "openapi")]
18use utoipa::ToSchema;
19
20/// File metadata without content
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[cfg_attr(feature = "openapi", derive(ToSchema))]
23pub struct FileInfo {
24    /// Internal database UUID for this file entry.
25    #[cfg_attr(
26        feature = "openapi",
27        schema(example = "550e8400-e29b-41d4-a716-446655440000")
28    )]
29    pub id: Uuid,
30    /// UUID of the owning session.
31    #[cfg_attr(
32        feature = "openapi",
33        schema(example = "01933b5a-0000-7000-8000-000000000001")
34    )]
35    pub session_id: Uuid,
36    /// Absolute path within the session workspace (e.g. `/notes.md`).
37    #[cfg_attr(feature = "openapi", schema(example = "/notes.md"))]
38    pub path: String,
39    /// File or directory name (the last segment of `path`).
40    #[cfg_attr(feature = "openapi", schema(example = "notes.md"))]
41    pub name: String,
42    /// `true` when this entry represents a directory; `false` for a regular file.
43    #[cfg_attr(feature = "openapi", schema(example = false))]
44    pub is_directory: bool,
45    /// Whether the entry was marked read-only at creation. Read-only entries cannot be edited or deleted by the session.
46    #[cfg_attr(feature = "openapi", schema(example = false))]
47    pub is_readonly: bool,
48    /// File size in bytes. `0` for directories.
49    #[cfg_attr(feature = "openapi", schema(example = 4096))]
50    pub size_bytes: i64,
51    /// Timestamp when this entry was created (RFC 3339).
52    #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:14:00Z"))]
53    pub created_at: DateTime<Utc>,
54    /// Timestamp when this entry was last updated (RFC 3339).
55    #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:15:30Z"))]
56    pub updated_at: DateTime<Utc>,
57}
58
59impl FileInfo {
60    /// Extract file name from path
61    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    /// Get parent directory path
70    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/// Complete file with content
81#[derive(Debug, Clone, Serialize, Deserialize)]
82#[cfg_attr(feature = "openapi", derive(ToSchema))]
83pub struct SessionFile {
84    /// Internal database UUID for this file entry.
85    pub id: Uuid,
86    /// UUID of the owning session.
87    pub session_id: Uuid,
88    /// Absolute path within the session workspace (e.g. `/notes.md`).
89    pub path: String,
90    /// File or directory name (the last segment of `path`).
91    pub name: String,
92    /// File content. Encoding is controlled by the `encoding` field: plain UTF-8 text for `text`, base64-encoded bytes for `base64`. `None` for directories and when this is a metadata-only listing.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub content: Option<String>,
95    /// Content encoding for the `content` field: `text` (UTF-8) or `base64` (binary).
96    #[serde(default = "default_encoding")]
97    pub encoding: String,
98    /// `true` when this entry represents a directory; `false` for a regular file.
99    pub is_directory: bool,
100    /// Whether the entry was marked read-only at creation. Read-only entries cannot be edited or deleted by the session.
101    pub is_readonly: bool,
102    /// File size in bytes. `0` for directories.
103    pub size_bytes: i64,
104    /// Timestamp when this entry was created (RFC 3339).
105    pub created_at: DateTime<Utc>,
106    /// Timestamp when this entry was last updated (RFC 3339).
107    pub updated_at: DateTime<Utc>,
108}
109
110/// Starter file copied into a new session from an agent or harness.
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112#[cfg_attr(feature = "openapi", derive(ToSchema))]
113pub struct InitialFile {
114    /// Absolute path within the session workspace. `/workspace` prefix is accepted.
115    pub path: String,
116    /// File content: plain text or base64-encoded binary.
117    pub content: String,
118    /// Content encoding: `text` or `base64`.
119    #[serde(default = "default_encoding")]
120    pub encoding: String,
121    /// Prevent session-side edits or deletes when true.
122    #[serde(default)]
123    pub is_readonly: bool,
124}
125
126fn default_encoding() -> String {
127    "text".to_string()
128}
129
130impl SessionFile {
131    /// Check if content is likely text based on bytes
132    pub fn is_text_content(bytes: &[u8]) -> bool {
133        // Quick heuristic: check first 8KB for null bytes
134        let check_len = bytes.len().min(8192);
135        !bytes[..check_len].contains(&0)
136    }
137
138    /// Convert raw bytes to content string with appropriate encoding
139    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    /// Decode content string to raw bytes
151    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/// File stat information
160#[derive(Debug, Clone, Serialize, Deserialize)]
161#[cfg_attr(feature = "openapi", derive(ToSchema))]
162pub struct FileStat {
163    /// Absolute path within the session workspace.
164    pub path: String,
165    /// File or directory name (last segment of `path`).
166    pub name: String,
167    /// `true` when this entry represents a directory.
168    pub is_directory: bool,
169    /// Whether the entry is read-only.
170    pub is_readonly: bool,
171    /// File size in bytes. `0` for directories.
172    pub size_bytes: i64,
173    /// Timestamp when this entry was created (RFC 3339).
174    pub created_at: DateTime<Utc>,
175    /// Timestamp when this entry was last updated (RFC 3339).
176    pub updated_at: DateTime<Utc>,
177}
178
179/// Grep match result
180#[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/// Options for a bounded grep scan.
189#[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/// One numbered line in a contextual grep block.
213#[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/// A contiguous contextual range. Overlapping match windows are merged.
222#[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/// Backend result for a bounded grep scan.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234#[cfg_attr(feature = "openapi", derive(ToSchema))]
235pub struct GrepSearchResult {
236    /// Flat matches are populated when both context values are zero.
237    pub matches: Vec<GrepMatch>,
238    /// Context blocks are populated when either context value is non-zero.
239    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
248/// Build a bounded result from text files already loaded by a backend scan.
249/// Paths are sorted so match offsets are stable across backend implementations.
250pub fn build_grep_search_result(
251    mut files: Vec<(String, String)>,
252    regex: &regex::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
327/// Apply stable match pagination and the response byte budget to flat matches.
328pub 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
344/// Merge results from distinct mounts, then apply one global match window.
345pub 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
499// Include a comma per entry so a collection of entries never exceeds the reported budget.
500fn 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/// Grep result for a file
557#[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        // Binary data with null byte
604        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}