winx_code_agent/utils/
scratch_file.rs1use std::fs::{self, OpenOptions};
17use std::io::Write;
18use std::path::{Path, PathBuf};
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22use tracing::debug;
23
24const SCRATCH_SUBDIR: &str = ".winx/scratch";
25
26static SCRATCH_SEQ: AtomicU64 = AtomicU64::new(0);
30
31const SCRATCH_MAX_AGE: Duration = Duration::from_secs(3600);
33
34fn scratch_dir(workspace_root: &Path) -> PathBuf {
35 workspace_root.join(SCRATCH_SUBDIR)
36}
37
38pub fn new_scratch_path(workspace_root: &Path) -> Option<PathBuf> {
42 let dir = scratch_dir(workspace_root);
43 if let Err(e) = fs::create_dir_all(&dir) {
44 debug!("scratch: create_dir_all {} failed: {e}", dir.display());
45 return None;
46 }
47 prune_scratch_dir(workspace_root, SCRATCH_MAX_AGE);
48 let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_nanos();
49 let seq = SCRATCH_SEQ.fetch_add(1, Ordering::Relaxed);
50 Some(dir.join(format!("bash-output-{}-{nonce:x}-{seq:x}.txt", std::process::id())))
51}
52
53pub fn append_scratch(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
55 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
56 file.write_all(bytes)
57}
58
59pub fn prune_scratch_dir(workspace_root: &Path, max_age: Duration) {
63 let dir = scratch_dir(workspace_root);
64 let Ok(entries) = fs::read_dir(&dir) else { return };
65 let cutoff = SystemTime::now().checked_sub(max_age).unwrap_or(UNIX_EPOCH);
66 for entry in entries.flatten() {
67 let path = entry.path();
68 let stale =
69 fs::metadata(&path).and_then(|meta| meta.modified()).is_ok_and(|mtime| mtime < cutoff);
70 if stale {
71 if let Err(e) = fs::remove_file(&path) {
72 debug!("scratch: prune remove {} failed: {e}", path.display());
73 }
74 }
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 #![allow(clippy::unwrap_used, clippy::expect_used)]
81 use super::*;
82 use std::thread::sleep;
83 use tempfile::TempDir;
84
85 #[test]
86 fn new_scratch_path_is_under_winx_scratch() {
87 let ws = TempDir::new().unwrap();
88 let path = new_scratch_path(ws.path()).expect("path");
89 assert!(
90 path.starts_with(ws.path().join(".winx/scratch")),
91 "path outside scratch: {path:?}"
92 );
93 assert!(path.parent().is_some_and(Path::exists), "scratch dir not created");
94 }
95
96 #[test]
97 fn new_scratch_path_is_unique_per_call() {
98 let ws = TempDir::new().unwrap();
101 let a = new_scratch_path(ws.path()).unwrap();
102 let b = new_scratch_path(ws.path()).unwrap();
103 assert_ne!(a, b, "consecutive scratch paths must be unique");
104 }
105
106 #[test]
107 fn append_scratch_accumulates() {
108 let ws = TempDir::new().unwrap();
109 let path = new_scratch_path(ws.path()).unwrap();
110 append_scratch(&path, b"head-one\n").unwrap();
111 append_scratch(&path, b"head-two\n").unwrap();
112 assert_eq!(fs::read_to_string(&path).unwrap(), "head-one\nhead-two\n");
113 }
114
115 #[test]
116 fn prune_removes_stale_keeps_fresh() {
117 let ws = TempDir::new().unwrap();
118 let old = new_scratch_path(ws.path()).unwrap();
119 append_scratch(&old, b"old").unwrap();
120 sleep(Duration::from_millis(15));
121 prune_scratch_dir(ws.path(), Duration::from_millis(1));
123 assert!(!old.exists(), "stale scratch file should be pruned");
124 let fresh = new_scratch_path(ws.path()).unwrap();
125 append_scratch(&fresh, b"fresh").unwrap();
126 prune_scratch_dir(ws.path(), Duration::from_secs(3600));
127 assert!(fresh.exists(), "fresh scratch file must survive");
128 }
129
130 #[test]
131 fn new_scratch_path_returns_none_on_unwritable_root() {
132 let path = new_scratch_path(Path::new("/proc/winx-nonexistent-xyz"));
134 assert!(path.is_none(), "expected None for unwritable root");
135 }
136}