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 text bytes returned by one contextual 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(|item| item.line.len()).sum::<usize>()
433        + blocks
434            .iter()
435            .flat_map(|block| &block.lines)
436            .map(|item| item.line.len())
437            .sum::<usize>();
438    let mut bytes_returned = 0usize;
439    let mut returned_matches = 0usize;
440    let mut byte_truncated = false;
441    let mut returned_flat = Vec::new();
442    let mut returned_blocks = Vec::new();
443
444    for mut item in flat {
445        let remaining = options.max_bytes.saturating_sub(bytes_returned);
446        if item.line.len() > remaining {
447            if !returned_flat.is_empty() || remaining == 0 {
448                byte_truncated = true;
449                break;
450            }
451            item.line = truncate_utf8(&item.line, remaining).to_string();
452            byte_truncated = true;
453        }
454        bytes_returned += item.line.len();
455        returned_matches += 1;
456        returned_flat.push(item);
457        if byte_truncated {
458            break;
459        }
460    }
461
462    for mut block in blocks {
463        let block_bytes: usize = block.lines.iter().map(|item| item.line.len()).sum();
464        let remaining = options.max_bytes.saturating_sub(bytes_returned);
465        if block_bytes > remaining {
466            if !returned_blocks.is_empty() || remaining == 0 {
467                byte_truncated = true;
468                break;
469            }
470            let mut left = remaining;
471            for line in &mut block.lines {
472                if line.line.len() > left {
473                    line.line = truncate_utf8(&line.line, left).to_string();
474                    left = 0;
475                } else {
476                    left -= line.line.len();
477                }
478            }
479            byte_truncated = true;
480        }
481        bytes_returned += block
482            .lines
483            .iter()
484            .map(|item| item.line.len())
485            .sum::<usize>();
486        returned_matches += block.match_line_numbers.len();
487        returned_blocks.push(block);
488        if byte_truncated {
489            break;
490        }
491    }
492
493    let next = options.offset.saturating_add(returned_matches);
494    GrepSearchResult {
495        matches: returned_flat,
496        blocks: returned_blocks,
497        total_matches,
498        returned_matches,
499        bytes_returned,
500        bytes_total,
501        next_offset: (next < total_matches).then_some(next),
502        byte_truncated,
503    }
504}
505
506fn truncate_utf8(value: &str, max_bytes: usize) -> &str {
507    let mut end = max_bytes.min(value.len());
508    while end > 0 && !value.is_char_boundary(end) {
509        end -= 1;
510    }
511    &value[..end]
512}
513
514/// Grep result for a file
515#[derive(Debug, Clone, Serialize, Deserialize)]
516#[cfg_attr(feature = "openapi", derive(ToSchema))]
517pub struct GrepResult {
518    pub path: String,
519    pub matches: Vec<GrepMatch>,
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    #[test]
527    fn test_name_from_path() {
528        assert_eq!(FileInfo::name_from_path("/"), "/");
529        assert_eq!(FileInfo::name_from_path("/foo"), "foo");
530        assert_eq!(FileInfo::name_from_path("/foo/bar"), "bar");
531        assert_eq!(FileInfo::name_from_path("/foo/bar/baz.txt"), "baz.txt");
532    }
533
534    #[test]
535    fn test_parent_path() {
536        assert_eq!(FileInfo::parent_path("/"), None);
537        assert_eq!(FileInfo::parent_path("/foo"), Some("/".to_string()));
538        assert_eq!(FileInfo::parent_path("/foo/bar"), Some("/foo".to_string()));
539        assert_eq!(
540            FileInfo::parent_path("/foo/bar/baz"),
541            Some("/foo/bar".to_string())
542        );
543    }
544
545    #[test]
546    fn test_is_text_content() {
547        assert!(SessionFile::is_text_content(b"hello world"));
548        assert!(SessionFile::is_text_content(b"line1\nline2\n"));
549        assert!(!SessionFile::is_text_content(b"hello\0world"));
550    }
551
552    #[test]
553    fn test_encode_content_text() {
554        let (content, encoding) = SessionFile::encode_content(b"hello world");
555        assert_eq!(content, "hello world");
556        assert_eq!(encoding, "text");
557    }
558
559    #[test]
560    fn test_encode_content_binary() {
561        // Binary data with null byte
562        let binary = b"\x89PNG\r\n\x1a\n\0";
563        let (content, encoding) = SessionFile::encode_content(binary);
564        assert_eq!(encoding, "base64");
565        assert!(!content.is_empty());
566    }
567
568    #[test]
569    fn test_decode_content_text() {
570        let decoded = SessionFile::decode_content("hello world", "text").unwrap();
571        assert_eq!(decoded, b"hello world");
572    }
573
574    #[test]
575    fn test_decode_content_base64() {
576        let decoded = SessionFile::decode_content("aGVsbG8=", "base64").unwrap();
577        assert_eq!(decoded, b"hello");
578    }
579
580    #[test]
581    fn test_encode_decode_roundtrip() {
582        let original = b"Test content with special chars: \xc3\xa9\xc3\xa0";
583        let (encoded, encoding) = SessionFile::encode_content(original);
584        let decoded = SessionFile::decode_content(&encoded, &encoding).unwrap();
585        assert_eq!(decoded, original);
586    }
587
588    #[test]
589    fn test_file_info_serialization() {
590        let file_info = FileInfo {
591            id: Uuid::nil(),
592            session_id: Uuid::nil(),
593            path: "/test.txt".to_string(),
594            name: "test.txt".to_string(),
595            is_directory: false,
596            is_readonly: false,
597            size_bytes: 100,
598            created_at: DateTime::default(),
599            updated_at: DateTime::default(),
600        };
601
602        let json = serde_json::to_string(&file_info).unwrap();
603        assert!(json.contains("\"path\":\"/test.txt\""));
604        assert!(json.contains("\"is_directory\":false"));
605    }
606
607    #[test]
608    fn test_grep_result_serialization() {
609        let result = GrepResult {
610            path: "/test.txt".to_string(),
611            matches: vec![GrepMatch {
612                path: "/test.txt".to_string(),
613                line_number: 1,
614                line: "hello world".to_string(),
615            }],
616        };
617
618        let json = serde_json::to_string(&result).unwrap();
619        assert!(json.contains("\"line_number\":1"));
620        assert!(json.contains("\"line\":\"hello world\""));
621    }
622
623    #[test]
624    fn merge_context_results_applies_one_match_window_without_duplicate_lines() {
625        let block = |path: &str, start: usize, matches: &[usize]| GrepContextBlock {
626            path: path.to_string(),
627            start_line: start,
628            end_line: start + 2,
629            match_line_numbers: matches.to_vec(),
630            lines: (start..=start + 2)
631                .map(|line_number| GrepContextLine {
632                    line_number,
633                    line: format!("line {line_number}"),
634                    is_match: matches.contains(&line_number),
635                })
636                .collect(),
637        };
638        let result = |blocks| GrepSearchResult {
639            matches: Vec::new(),
640            blocks,
641            total_matches: 0,
642            returned_matches: 0,
643            bytes_returned: 0,
644            bytes_total: 0,
645            next_offset: None,
646            byte_truncated: false,
647        };
648        let options = GrepOptions {
649            before_context: 1,
650            after_context: 1,
651            offset: 1,
652            limit: 2,
653            ..GrepOptions::default()
654        };
655
656        let merged = merge_grep_search_results(
657            vec![
658                result(vec![block("/a.txt", 1, &[2]), block("/a.txt", 3, &[4])]),
659                result(vec![block("/b.txt", 4, &[5])]),
660            ],
661            &options,
662        );
663
664        assert_eq!(merged.total_matches, 3);
665        assert_eq!(merged.returned_matches, 2);
666        assert_eq!(merged.next_offset, None);
667        assert_eq!(merged.blocks.len(), 2);
668        assert_eq!(merged.blocks[0].match_line_numbers, vec![4]);
669        assert_eq!(merged.blocks[1].match_line_numbers, vec![5]);
670        assert_eq!(
671            merged.blocks[0]
672                .lines
673                .iter()
674                .map(|line| line.line_number)
675                .collect::<Vec<_>>(),
676            vec![3, 4, 5]
677        );
678    }
679}