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