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 uuid::Uuid;
10
11#[cfg(feature = "openapi")]
12use utoipa::ToSchema;
13
14/// File metadata without content
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[cfg_attr(feature = "openapi", derive(ToSchema))]
17pub struct FileInfo {
18    /// Internal database UUID for this file entry.
19    #[cfg_attr(
20        feature = "openapi",
21        schema(example = "550e8400-e29b-41d4-a716-446655440000")
22    )]
23    pub id: Uuid,
24    /// UUID of the owning session.
25    #[cfg_attr(
26        feature = "openapi",
27        schema(example = "01933b5a-0000-7000-8000-000000000001")
28    )]
29    pub session_id: Uuid,
30    /// Absolute path within the session workspace (e.g. `/notes.md`).
31    #[cfg_attr(feature = "openapi", schema(example = "/notes.md"))]
32    pub path: String,
33    /// File or directory name (the last segment of `path`).
34    #[cfg_attr(feature = "openapi", schema(example = "notes.md"))]
35    pub name: String,
36    /// `true` when this entry represents a directory; `false` for a regular file.
37    #[cfg_attr(feature = "openapi", schema(example = false))]
38    pub is_directory: bool,
39    /// Whether the entry was marked read-only at creation. Read-only entries cannot be edited or deleted by the session.
40    #[cfg_attr(feature = "openapi", schema(example = false))]
41    pub is_readonly: bool,
42    /// File size in bytes. `0` for directories.
43    #[cfg_attr(feature = "openapi", schema(example = 4096))]
44    pub size_bytes: i64,
45    /// Timestamp when this entry was created (RFC 3339).
46    #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:14:00Z"))]
47    pub created_at: DateTime<Utc>,
48    /// Timestamp when this entry was last updated (RFC 3339).
49    #[cfg_attr(feature = "openapi", schema(example = "2026-05-25T10:15:30Z"))]
50    pub updated_at: DateTime<Utc>,
51}
52
53impl FileInfo {
54    /// Extract file name from path
55    pub fn name_from_path(path: &str) -> String {
56        if path == "/" {
57            "/".to_string()
58        } else {
59            path.rsplit('/').next().unwrap_or(path).to_string()
60        }
61    }
62
63    /// Get parent directory path
64    pub fn parent_path(path: &str) -> Option<String> {
65        if path == "/" {
66            None
67        } else {
68            let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("/");
69            Some(if parent.is_empty() { "/" } else { parent }.to_string())
70        }
71    }
72}
73
74/// Complete file with content
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[cfg_attr(feature = "openapi", derive(ToSchema))]
77pub struct SessionFile {
78    /// Internal database UUID for this file entry.
79    pub id: Uuid,
80    /// UUID of the owning session.
81    pub session_id: Uuid,
82    /// Absolute path within the session workspace (e.g. `/notes.md`).
83    pub path: String,
84    /// File or directory name (the last segment of `path`).
85    pub name: String,
86    /// 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.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub content: Option<String>,
89    /// Content encoding for the `content` field: `text` (UTF-8) or `base64` (binary).
90    #[serde(default = "default_encoding")]
91    pub encoding: String,
92    /// `true` when this entry represents a directory; `false` for a regular file.
93    pub is_directory: bool,
94    /// Whether the entry was marked read-only at creation. Read-only entries cannot be edited or deleted by the session.
95    pub is_readonly: bool,
96    /// File size in bytes. `0` for directories.
97    pub size_bytes: i64,
98    /// Timestamp when this entry was created (RFC 3339).
99    pub created_at: DateTime<Utc>,
100    /// Timestamp when this entry was last updated (RFC 3339).
101    pub updated_at: DateTime<Utc>,
102}
103
104/// Starter file copied into a new session from an agent or harness.
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106#[cfg_attr(feature = "openapi", derive(ToSchema))]
107pub struct InitialFile {
108    /// Absolute path within the session workspace. `/workspace` prefix is accepted.
109    pub path: String,
110    /// File content: plain text or base64-encoded binary.
111    pub content: String,
112    /// Content encoding: `text` or `base64`.
113    #[serde(default = "default_encoding")]
114    pub encoding: String,
115    /// Prevent session-side edits or deletes when true.
116    #[serde(default)]
117    pub is_readonly: bool,
118}
119
120fn default_encoding() -> String {
121    "text".to_string()
122}
123
124impl SessionFile {
125    /// Check if content is likely text based on bytes
126    pub fn is_text_content(bytes: &[u8]) -> bool {
127        // Quick heuristic: check first 8KB for null bytes
128        let check_len = bytes.len().min(8192);
129        !bytes[..check_len].contains(&0)
130    }
131
132    /// Convert raw bytes to content string with appropriate encoding
133    pub fn encode_content(bytes: &[u8]) -> (String, String) {
134        if Self::is_text_content(bytes) {
135            match String::from_utf8(bytes.to_vec()) {
136                Ok(text) => (text, "text".to_string()),
137                Err(_) => (BASE64.encode(bytes), "base64".to_string()),
138            }
139        } else {
140            (BASE64.encode(bytes), "base64".to_string())
141        }
142    }
143
144    /// Decode content string to raw bytes
145    pub fn decode_content(content: &str, encoding: &str) -> Result<Vec<u8>, base64::DecodeError> {
146        match encoding {
147            "base64" => BASE64.decode(content),
148            _ => Ok(content.as_bytes().to_vec()),
149        }
150    }
151}
152
153/// File stat information
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[cfg_attr(feature = "openapi", derive(ToSchema))]
156pub struct FileStat {
157    /// Absolute path within the session workspace.
158    pub path: String,
159    /// File or directory name (last segment of `path`).
160    pub name: String,
161    /// `true` when this entry represents a directory.
162    pub is_directory: bool,
163    /// Whether the entry is read-only.
164    pub is_readonly: bool,
165    /// File size in bytes. `0` for directories.
166    pub size_bytes: i64,
167    /// Timestamp when this entry was created (RFC 3339).
168    pub created_at: DateTime<Utc>,
169    /// Timestamp when this entry was last updated (RFC 3339).
170    pub updated_at: DateTime<Utc>,
171}
172
173/// Grep match result
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[cfg_attr(feature = "openapi", derive(ToSchema))]
176pub struct GrepMatch {
177    pub path: String,
178    pub line_number: usize,
179    pub line: String,
180}
181
182/// Grep result for a file
183#[derive(Debug, Clone, Serialize, Deserialize)]
184#[cfg_attr(feature = "openapi", derive(ToSchema))]
185pub struct GrepResult {
186    pub path: String,
187    pub matches: Vec<GrepMatch>,
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_name_from_path() {
196        assert_eq!(FileInfo::name_from_path("/"), "/");
197        assert_eq!(FileInfo::name_from_path("/foo"), "foo");
198        assert_eq!(FileInfo::name_from_path("/foo/bar"), "bar");
199        assert_eq!(FileInfo::name_from_path("/foo/bar/baz.txt"), "baz.txt");
200    }
201
202    #[test]
203    fn test_parent_path() {
204        assert_eq!(FileInfo::parent_path("/"), None);
205        assert_eq!(FileInfo::parent_path("/foo"), Some("/".to_string()));
206        assert_eq!(FileInfo::parent_path("/foo/bar"), Some("/foo".to_string()));
207        assert_eq!(
208            FileInfo::parent_path("/foo/bar/baz"),
209            Some("/foo/bar".to_string())
210        );
211    }
212
213    #[test]
214    fn test_is_text_content() {
215        assert!(SessionFile::is_text_content(b"hello world"));
216        assert!(SessionFile::is_text_content(b"line1\nline2\n"));
217        assert!(!SessionFile::is_text_content(b"hello\0world"));
218    }
219
220    #[test]
221    fn test_encode_content_text() {
222        let (content, encoding) = SessionFile::encode_content(b"hello world");
223        assert_eq!(content, "hello world");
224        assert_eq!(encoding, "text");
225    }
226
227    #[test]
228    fn test_encode_content_binary() {
229        // Binary data with null byte
230        let binary = b"\x89PNG\r\n\x1a\n\0";
231        let (content, encoding) = SessionFile::encode_content(binary);
232        assert_eq!(encoding, "base64");
233        assert!(!content.is_empty());
234    }
235
236    #[test]
237    fn test_decode_content_text() {
238        let decoded = SessionFile::decode_content("hello world", "text").unwrap();
239        assert_eq!(decoded, b"hello world");
240    }
241
242    #[test]
243    fn test_decode_content_base64() {
244        let decoded = SessionFile::decode_content("aGVsbG8=", "base64").unwrap();
245        assert_eq!(decoded, b"hello");
246    }
247
248    #[test]
249    fn test_encode_decode_roundtrip() {
250        let original = b"Test content with special chars: \xc3\xa9\xc3\xa0";
251        let (encoded, encoding) = SessionFile::encode_content(original);
252        let decoded = SessionFile::decode_content(&encoded, &encoding).unwrap();
253        assert_eq!(decoded, original);
254    }
255
256    #[test]
257    fn test_file_info_serialization() {
258        let file_info = FileInfo {
259            id: Uuid::nil(),
260            session_id: Uuid::nil(),
261            path: "/test.txt".to_string(),
262            name: "test.txt".to_string(),
263            is_directory: false,
264            is_readonly: false,
265            size_bytes: 100,
266            created_at: DateTime::default(),
267            updated_at: DateTime::default(),
268        };
269
270        let json = serde_json::to_string(&file_info).unwrap();
271        assert!(json.contains("\"path\":\"/test.txt\""));
272        assert!(json.contains("\"is_directory\":false"));
273    }
274
275    #[test]
276    fn test_grep_result_serialization() {
277        let result = GrepResult {
278            path: "/test.txt".to_string(),
279            matches: vec![GrepMatch {
280                path: "/test.txt".to_string(),
281                line_number: 1,
282                line: "hello world".to_string(),
283            }],
284        };
285
286        let json = serde_json::to_string(&result).unwrap();
287        assert!(json.contains("\"line_number\":1"));
288        assert!(json.contains("\"line\":\"hello world\""));
289    }
290}