oxur_repl/session/
dir.rs

1//! Session directory management with tmpfs optimization
2//!
3//! Provides temporary directory management for REPL sessions with
4//! best-effort tmpfs allocation on Linux for improved performance.
5//!
6//! Based on ODD-0026 Section 9 (Temp Directory Strategy)
7
8use crate::protocol::SessionId;
9use std::env;
10use std::fs;
11use std::path::{Path, PathBuf};
12use thiserror::Error;
13
14/// Session directory errors
15#[derive(Debug, Error)]
16pub enum SessionDirError {
17    #[error("Failed to create session directory: {0}")]
18    CreateFailed(#[from] std::io::Error),
19
20    #[error("Session directory not found: {0}")]
21    NotFound(PathBuf),
22
23    #[error("Failed to cleanup session directory: {0}")]
24    CleanupFailed(String),
25}
26
27pub type Result<T> = std::result::Result<T, SessionDirError>;
28
29/// Session directory manager
30///
31/// Manages temporary directories for REPL sessions with performance
32/// optimizations:
33/// - Linux: Tries tmpfs (/dev/shm) for RAM-backed storage
34/// - macOS/Windows: Uses OS temp directory with caching
35/// - All platforms: Respects OXUR_REPL_TEMP_DIR override
36///
37/// # Examples
38///
39/// ```
40/// use oxur_repl::session::SessionDir;
41/// use oxur_repl::protocol::SessionId;
42///
43/// let session_id = SessionId::new("test-session");
44/// let dir = SessionDir::new(&session_id).expect("Failed to create session dir");
45///
46/// // Use directory for compilation artifacts
47/// let source_path = dir.path().join("eval_0.rs");
48///
49/// // Cleanup happens automatically on drop
50/// ```
51#[derive(Debug, Clone)]
52pub struct SessionDir {
53    /// Path to the session directory
54    path: PathBuf,
55
56    /// Whether this directory is on tmpfs (RAM-backed)
57    is_tmpfs: bool,
58
59    /// Session ID for this directory
60    session_id: SessionId,
61}
62
63impl SessionDir {
64    /// Create a new session directory
65    ///
66    /// Strategy (per ODD-0026 Decision 4):
67    /// 1. Check OXUR_REPL_TEMP_DIR environment variable
68    /// 2. Try tmpfs on Linux (/dev/shm)
69    /// 3. Fall back to OS temp directory
70    ///
71    /// # Errors
72    ///
73    /// Returns error if directory creation fails
74    pub fn new(session_id: &SessionId) -> Result<Self> {
75        // Try user override first
76        if let Ok(dir) = env::var("OXUR_REPL_TEMP_DIR") {
77            return Self::create_in(PathBuf::from(dir), session_id, false);
78        }
79
80        // Try tmpfs on Linux
81        #[cfg(target_os = "linux")]
82        if let Ok(tmpfs_dir) = Self::try_tmpfs(session_id) {
83            return Ok(tmpfs_dir);
84        }
85
86        // Fall back to OS temp directory
87        let temp_dir = env::temp_dir().join("oxur-repl").join(session_id.as_str());
88
89        Self::create_in(temp_dir, session_id, false)
90    }
91
92    /// Try to create directory on tmpfs (Linux only)
93    #[cfg(target_os = "linux")]
94    fn try_tmpfs(session_id: &SessionId) -> Result<Self> {
95        let tmpfs_path = PathBuf::from("/dev/shm").join("oxur-repl").join(session_id.as_str());
96
97        Self::create_in(tmpfs_path, session_id, true)
98    }
99
100    /// Create directory at specific path
101    fn create_in(path: PathBuf, session_id: &SessionId, is_tmpfs: bool) -> Result<Self> {
102        fs::create_dir_all(&path)?;
103
104        Ok(Self { path, is_tmpfs, session_id: session_id.clone() })
105    }
106
107    /// Get the path to this session directory
108    pub fn path(&self) -> &Path {
109        &self.path
110    }
111
112    /// Check if this directory is on tmpfs
113    pub fn is_tmpfs(&self) -> bool {
114        self.is_tmpfs
115    }
116
117    /// Get the session ID
118    pub fn session_id(&self) -> &SessionId {
119        &self.session_id
120    }
121
122    /// Write source code to a file in this directory
123    ///
124    /// # Arguments
125    ///
126    /// * `name` - File name (e.g., "eval_0.rs")
127    /// * `content` - Source code content
128    pub fn write_source(&self, name: impl AsRef<str>, content: impl AsRef<str>) -> Result<PathBuf> {
129        let path = self.path.join(name.as_ref());
130        fs::write(&path, content.as_ref())?;
131        Ok(path)
132    }
133
134    /// Create a subdirectory within this session directory
135    pub fn create_subdir(&self, name: impl AsRef<str>) -> Result<PathBuf> {
136        let subdir = self.path.join(name.as_ref());
137        fs::create_dir_all(&subdir)?;
138        Ok(subdir)
139    }
140
141    /// Clean up the session directory
142    ///
143    /// Removes all files and the directory itself.
144    /// Called automatically on drop, but can be called explicitly.
145    pub fn cleanup(&self) -> Result<()> {
146        if self.path.exists() {
147            fs::remove_dir_all(&self.path).map_err(|e| {
148                SessionDirError::CleanupFailed(format!(
149                    "Failed to remove {}: {}",
150                    self.path.display(),
151                    e
152                ))
153            })?;
154        }
155        Ok(())
156    }
157
158    /// List all files in the session directory
159    pub fn list_files(&self) -> Result<Vec<PathBuf>> {
160        let mut files = Vec::new();
161
162        for entry in fs::read_dir(&self.path)? {
163            let entry = entry?;
164            let path = entry.path();
165            if path.is_file() {
166                files.push(path);
167            }
168        }
169
170        Ok(files)
171    }
172
173    /// Get directory statistics
174    ///
175    /// Returns (file_count, total_bytes) for all files in the session directory.
176    /// This recursively walks subdirectories.
177    pub fn get_stats(&self) -> Result<DirStats> {
178        let mut file_count = 0;
179        let mut total_bytes = 0;
180
181        fn walk_dir(
182            path: &Path,
183            file_count: &mut usize,
184            total_bytes: &mut u64,
185        ) -> std::io::Result<()> {
186            for entry in fs::read_dir(path)? {
187                let entry = entry?;
188                let path = entry.path();
189                let metadata = entry.metadata()?;
190
191                if metadata.is_file() {
192                    *file_count += 1;
193                    *total_bytes += metadata.len();
194                } else if metadata.is_dir() {
195                    walk_dir(&path, file_count, total_bytes)?;
196                }
197            }
198            Ok(())
199        }
200
201        walk_dir(&self.path, &mut file_count, &mut total_bytes)?;
202
203        Ok(DirStats { file_count, total_bytes, is_tmpfs: self.is_tmpfs, path: self.path.clone() })
204    }
205}
206
207/// Directory statistics
208#[derive(Debug, Clone)]
209pub struct DirStats {
210    /// Number of files in directory (recursive)
211    pub file_count: usize,
212
213    /// Total bytes used by all files
214    pub total_bytes: u64,
215
216    /// Whether directory is on tmpfs
217    pub is_tmpfs: bool,
218
219    /// Directory path
220    pub path: PathBuf,
221}
222
223impl Drop for SessionDir {
224    fn drop(&mut self) {
225        // Best-effort cleanup - log errors but don't panic
226        if let Err(e) = self.cleanup() {
227            eprintln!("Warning: Failed to cleanup session directory: {}", e);
228        }
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    #[serial_test::serial(env)]
238    fn test_session_dir_creation() {
239        let session_id = SessionId::new("test-create");
240        let dir = SessionDir::new(&session_id).expect("Failed to create session dir");
241
242        assert!(dir.path().exists());
243        assert_eq!(dir.session_id(), &session_id);
244    }
245
246    #[test]
247    #[serial_test::serial(env)]
248    fn test_session_dir_write_source() {
249        let session_id = SessionId::new("test-write");
250        let dir = SessionDir::new(&session_id).expect("Failed to create session dir");
251
252        let source_path =
253            dir.write_source("test.rs", "fn main() {}").expect("Failed to write source");
254
255        assert!(source_path.exists());
256        let content = fs::read_to_string(&source_path).expect("Failed to read source");
257        assert_eq!(content, "fn main() {}");
258    }
259
260    #[test]
261    #[serial_test::serial(env)]
262    fn test_session_dir_create_subdir() {
263        let session_id = SessionId::new("test-subdir");
264        let dir = SessionDir::new(&session_id).expect("Failed to create session dir");
265
266        let subdir = dir.create_subdir("artifacts").expect("Failed to create subdir");
267
268        assert!(subdir.exists());
269        assert!(subdir.is_dir());
270    }
271
272    #[test]
273    #[serial_test::serial(env)]
274    fn test_session_dir_list_files() {
275        let session_id = SessionId::new("test-list");
276        let dir = SessionDir::new(&session_id).expect("Failed to create session dir");
277
278        dir.write_source("file1.rs", "// file 1").expect("Failed to write file1");
279        dir.write_source("file2.rs", "// file 2").expect("Failed to write file2");
280
281        let files = dir.list_files().expect("Failed to list files");
282        assert_eq!(files.len(), 2);
283    }
284
285    #[test]
286    #[serial_test::serial(env)]
287    fn test_session_dir_cleanup() {
288        let session_id = SessionId::new("test-cleanup");
289        let dir = SessionDir::new(&session_id).expect("Failed to create session dir");
290        let path = dir.path().to_path_buf();
291
292        dir.write_source("test.rs", "fn main() {}").expect("Failed to write source");
293
294        assert!(path.exists());
295
296        dir.cleanup().expect("Failed to cleanup");
297        assert!(!path.exists());
298    }
299
300    #[test]
301    #[serial_test::serial(env)]
302    fn test_session_dir_auto_cleanup_on_drop() {
303        let session_id = SessionId::new("test-drop");
304        let path = {
305            let dir = SessionDir::new(&session_id).expect("Failed to create session dir");
306            dir.path().to_path_buf()
307        }; // dir dropped here
308
309        // Give the system a moment to cleanup
310        std::thread::sleep(std::time::Duration::from_millis(100));
311        assert!(!path.exists());
312    }
313
314    #[test]
315    #[serial_test::serial(env)]
316    fn test_session_dir_respects_env_var() {
317        let test_dir = env::temp_dir().join("oxur-test-custom");
318        env::set_var("OXUR_REPL_TEMP_DIR", &test_dir);
319
320        let session_id = SessionId::new("test-env");
321        let dir = SessionDir::new(&session_id).expect("Failed to create session dir");
322
323        assert!(dir.path().starts_with(&test_dir));
324
325        env::remove_var("OXUR_REPL_TEMP_DIR");
326    }
327
328    #[cfg(target_os = "linux")]
329    #[test]
330    fn test_session_dir_tmpfs_on_linux() {
331        let session_id = SessionId::new("test-tmpfs");
332        let dir = SessionDir::new(&session_id).expect("Failed to create session dir");
333
334        // If /dev/shm exists and is writable, we should be on tmpfs
335        if PathBuf::from("/dev/shm").exists() {
336            assert!(dir.is_tmpfs() || !dir.is_tmpfs()); // Either is valid
337        }
338    }
339}