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 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(), &regex, &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            &regex::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            &regex::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); // skipped match is context, not a second returned match
870        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        // The API budgets serialized entries, including a comma allowance.
885        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}