winx_code_agent/utils/
scratch_file.rs1use std::fs::{self, OpenOptions};
17use std::io::Write;
18use std::path::{Path, PathBuf};
19use std::sync::{
20 atomic::{AtomicU64, Ordering},
21 Mutex,
22};
23use std::time::{Duration, SystemTime, UNIX_EPOCH};
24
25use tracing::debug;
26
27const SCRATCH_SUBDIR: &str = ".winx/scratch";
28
29static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
33static SCRATCH_IO_LOCK: Mutex<()> = Mutex::new(());
34
35const SCRATCH_MAX_AGE: Duration = Duration::from_secs(3600);
37const SCRATCH_MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
39const SCRATCH_FILE_RESERVE_BYTES: u64 = 50 * 1024 * 1024;
41
42fn scratch_dir(workspace_root: &Path) -> PathBuf {
43 workspace_root.join(SCRATCH_SUBDIR)
44}
45
46pub fn new_scratch_path(workspace_root: &Path) -> Option<PathBuf> {
50 let _guard = SCRATCH_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
51 let dir = scratch_dir(workspace_root);
52 if let Err(e) = fs::create_dir_all(&dir) {
53 debug!("scratch: create_dir_all {} failed: {e}", dir.display());
54 return None;
55 }
56 prune_scratch_dir_unlocked(workspace_root, SCRATCH_MAX_AGE);
57 prune_scratch_to_size(&dir, SCRATCH_MAX_TOTAL_BYTES - SCRATCH_FILE_RESERVE_BYTES);
58 let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_nanos();
59 let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed);
60 Some(dir.join(format!("bash-output-{}-{nonce:x}-{seq:x}.txt", std::process::id())))
61}
62
63pub fn append_scratch(path: &Path, bytes: &[u8]) -> std::io::Result<usize> {
65 let _guard = SCRATCH_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
66 let Some(dir) = path.parent() else {
67 return Ok(0);
68 };
69 let used = scratch_dir_size(dir);
70 let writable = allowed_append_len(used, bytes.len());
71 if writable == 0 {
72 return Ok(0);
73 }
74 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
75 file.write_all(&bytes[..writable])?;
76 Ok(writable)
77}
78
79fn allowed_append_len(used: u64, requested: usize) -> usize {
80 usize::try_from(SCRATCH_MAX_TOTAL_BYTES.saturating_sub(used))
81 .unwrap_or(usize::MAX)
82 .min(requested)
83}
84
85pub fn prune_scratch_dir(workspace_root: &Path, max_age: Duration) {
89 let _guard = SCRATCH_IO_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
90 prune_scratch_dir_unlocked(workspace_root, max_age);
91}
92
93fn prune_scratch_dir_unlocked(workspace_root: &Path, max_age: Duration) {
94 let dir = scratch_dir(workspace_root);
95 let Ok(entries) = fs::read_dir(&dir) else { return };
96 let cutoff = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH);
97 for entry in entries.flatten() {
98 let path = entry.path();
99 let stale =
100 fs::metadata(&path).and_then(|meta| meta.modified()).is_ok_and(|mtime| mtime < cutoff);
101 if stale {
102 if let Err(e) = fs::remove_file(&path) {
103 debug!("scratch: prune remove {} failed: {e}", path.display());
104 }
105 }
106 }
107}
108
109fn scratch_dir_size(dir: &Path) -> u64 {
110 let Ok(entries) = fs::read_dir(dir) else { return 0 };
111 entries
112 .flatten()
113 .filter_map(|entry| entry.metadata().ok())
114 .filter(std::fs::Metadata::is_file)
115 .fold(0_u64, |total, metadata| total.saturating_add(metadata.len()))
116}
117
118fn prune_scratch_to_size(dir: &Path, target_bytes: u64) {
119 let Ok(entries) = fs::read_dir(dir) else { return };
120 let mut files: Vec<_> = entries
121 .flatten()
122 .filter_map(|entry| {
123 let metadata = entry.metadata().ok()?;
124 metadata
125 .is_file()
126 .then(|| (entry.path(), metadata.modified().unwrap_or(UNIX_EPOCH), metadata.len()))
127 })
128 .collect();
129 let mut total = files.iter().fold(0_u64, |sum, (_, _, len)| sum.saturating_add(*len));
130 files.sort_by_key(|(_, modified, _)| *modified);
131 for (path, _, len) in files {
132 if total <= target_bytes {
133 break;
134 }
135 if fs::remove_file(&path).is_ok() {
136 total = total.saturating_sub(len);
137 }
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 #![allow(clippy::unwrap_used, clippy::expect_used)]
144 use super::*;
145 use std::thread::sleep;
146 use tempfile::TempDir;
147
148 #[test]
149 fn new_scratch_path_is_under_winx_scratch() {
150 let ws = TempDir::new().unwrap();
151 let path = new_scratch_path(ws.path()).expect("path");
152 assert!(
153 path.starts_with(ws.path().join(".winx/scratch")),
154 "path outside scratch: {path:?}"
155 );
156 assert!(path.parent().is_some_and(Path::exists), "scratch dir not created");
157 }
158
159 #[test]
160 fn new_scratch_path_is_unique_per_call() {
161 let ws = TempDir::new().unwrap();
164 let a = new_scratch_path(ws.path()).unwrap();
165 let b = new_scratch_path(ws.path()).unwrap();
166 assert_ne!(a, b, "consecutive scratch paths must be unique");
167 }
168
169 #[test]
170 fn append_scratch_accumulates() {
171 let ws = TempDir::new().unwrap();
172 let path = new_scratch_path(ws.path()).unwrap();
173 append_scratch(&path, b"head-one\n").unwrap();
174 append_scratch(&path, b"head-two\n").unwrap();
175 assert_eq!(fs::read_to_string(&path).unwrap(), "head-one\nhead-two\n");
176 }
177
178 #[test]
179 fn prune_removes_stale_keeps_fresh() {
180 let ws = TempDir::new().unwrap();
181 let old = new_scratch_path(ws.path()).unwrap();
182 append_scratch(&old, b"old").unwrap();
183 sleep(Duration::from_millis(15));
184 prune_scratch_dir(ws.path(), Duration::from_millis(1));
186 assert!(!old.exists(), "stale scratch file should be pruned");
187 let fresh = new_scratch_path(ws.path()).unwrap();
188 append_scratch(&fresh, b"fresh").unwrap();
189 prune_scratch_dir(ws.path(), Duration::from_secs(3600));
190 assert!(fresh.exists(), "fresh scratch file must survive");
191 }
192
193 #[cfg(unix)] #[test]
195 fn new_scratch_path_returns_none_on_unwritable_root() {
196 let path = new_scratch_path(Path::new("/proc/winx-nonexistent-xyz"));
198 assert!(path.is_none(), "expected None for unwritable root");
199 }
200
201 #[test]
202 fn workspace_quota_caps_each_append() {
203 assert_eq!(allowed_append_len(SCRATCH_MAX_TOTAL_BYTES, 1024), 0);
204 assert_eq!(allowed_append_len(SCRATCH_MAX_TOTAL_BYTES - 7, 1024), 7);
205 assert_eq!(allowed_append_len(0, 1024), 1024);
206 }
207}