1use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime};
6
7use crate::config::OverflowConfig;
8
9fn overflow_dir(custom: Option<&Path>) -> PathBuf {
10 if let Some(p) = custom {
11 return p.to_path_buf();
12 }
13 dirs::home_dir()
14 .unwrap_or_else(|| PathBuf::from("."))
15 .join(".zeph/data/tool-output")
16}
17
18pub fn save_overflow(output: &str, config: &OverflowConfig) -> Option<String> {
21 if output.len() <= config.threshold {
22 return None;
23 }
24 let dir = overflow_dir(config.dir.as_deref());
25 if let Err(e) = std::fs::create_dir_all(&dir) {
26 tracing::warn!("failed to create overflow dir: {e}");
27 return None;
28 }
29 let canonical_dir = match std::fs::canonicalize(&dir) {
30 Ok(p) => p,
31 Err(e) => {
32 tracing::warn!("failed to canonicalize overflow dir: {e}");
33 return None;
34 }
35 };
36 let filename = format!("{}.txt", uuid::Uuid::new_v4());
37 let path = canonical_dir.join(&filename);
38
39 #[cfg(unix)]
40 {
41 use std::os::unix::fs::OpenOptionsExt;
42 match std::fs::OpenOptions::new()
43 .write(true)
44 .create_new(true)
45 .mode(0o600)
46 .open(&path)
47 .and_then(|mut f| {
48 use std::io::Write;
49 f.write_all(output.as_bytes())
50 }) {
51 Ok(()) => {}
52 Err(e) => {
53 tracing::warn!("failed to write overflow file: {e}");
54 return None;
55 }
56 }
57 }
58 #[cfg(not(unix))]
59 {
60 if let Err(e) = std::fs::write(&path, output) {
61 tracing::warn!("failed to write overflow file: {e}");
62 return None;
63 }
64 }
65
66 Some(filename)
67}
68
69pub fn cleanup_overflow_files(config: &OverflowConfig) {
71 let dir = overflow_dir(config.dir.as_deref());
72 let max_age = Duration::from_secs(config.retention_days * 86_400);
73 cleanup_overflow_files_in(&dir, max_age);
74}
75
76fn cleanup_overflow_files_in(dir: &Path, max_age: Duration) {
77 if let Err(e) = std::fs::create_dir_all(dir) {
78 tracing::warn!("failed to create overflow dir: {e}");
79 return;
80 }
81 let entries = match std::fs::read_dir(dir) {
82 Ok(e) => e,
83 Err(e) => {
84 tracing::warn!("failed to read overflow dir: {e}");
85 return;
86 }
87 };
88 let now = SystemTime::now();
89 for entry in entries.flatten() {
90 let Ok(meta) = std::fs::symlink_metadata(entry.path()) else {
92 continue;
93 };
94 if !meta.file_type().is_file() {
95 continue;
96 }
97 let Ok(modified) = meta.modified() else {
98 continue;
99 };
100 if now.duration_since(modified).unwrap_or_default() > max_age
103 && let Err(e) = std::fs::remove_file(entry.path())
104 {
105 tracing::warn!("failed to remove stale overflow file: {e}");
106 }
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn cfg(threshold: usize) -> OverflowConfig {
115 OverflowConfig {
116 threshold,
117 retention_days: 7,
118 dir: None,
119 }
120 }
121
122 #[test]
123 fn small_output_no_overflow() {
124 assert!(save_overflow("short", &cfg(50_000)).is_none());
125 }
126
127 #[test]
128 fn overflow_creates_file() {
129 let dir = tempfile::tempdir().unwrap();
130 let config = OverflowConfig {
131 threshold: 50_000,
132 retention_days: 7,
133 dir: Some(dir.path().to_path_buf()),
134 };
135 let long = "x".repeat(50_001);
136 let filename = save_overflow(&long, &config);
137 assert!(filename.is_some());
138 let name = filename.unwrap();
139 let p = dir.path().join(&name);
140 assert!(p.exists());
141 let contents = std::fs::read_to_string(&p).unwrap();
142 assert_eq!(contents.len(), long.len());
143 }
144
145 #[test]
146 fn custom_threshold_respected() {
147 let dir = tempfile::tempdir().unwrap();
148 let output = "x".repeat(1_001);
149
150 let config_low = OverflowConfig {
151 threshold: 1_000,
152 retention_days: 7,
153 dir: Some(dir.path().to_path_buf()),
154 };
155 assert!(save_overflow(&output, &config_low).is_some());
156
157 assert!(save_overflow(&output, &cfg(2_000)).is_none());
158 }
159
160 #[test]
161 fn save_returns_filename_only() {
162 let dir = tempfile::tempdir().unwrap();
163 let config = OverflowConfig {
164 threshold: 0,
165 retention_days: 7,
166 dir: Some(dir.path().to_path_buf()),
167 };
168 let filename = save_overflow("any", &config).unwrap();
169 assert!(!filename.contains('/'));
171 assert!(filename.ends_with(".txt"));
172 }
173
174 #[test]
175 fn custom_dir_used() {
176 let dir = tempfile::tempdir().unwrap();
177 let config = OverflowConfig {
178 threshold: 0,
179 retention_days: 7,
180 dir: Some(dir.path().to_path_buf()),
181 };
182 let filename = save_overflow("any", &config);
183 assert!(filename.is_some());
184 let p = dir.path().join(filename.unwrap());
185 assert!(p.exists());
186 }
187
188 #[test]
189 fn stale_files_removed() {
190 let dir = tempfile::tempdir().unwrap();
191 let file = dir.path().join("old.txt");
192 std::fs::write(&file, "data").unwrap();
193 let old_time = SystemTime::now() - Duration::from_secs(86_500);
194 let ft = filetime::FileTime::from_system_time(old_time);
195 filetime::set_file_mtime(&file, ft).unwrap();
196 cleanup_overflow_files_in(dir.path(), Duration::from_secs(86_400));
197 assert!(!file.exists());
198 }
199
200 #[test]
201 fn fresh_files_kept() {
202 let dir = tempfile::tempdir().unwrap();
203 let file = dir.path().join("fresh.txt");
204 std::fs::write(&file, "data").unwrap();
205 cleanup_overflow_files_in(dir.path(), Duration::from_secs(86_400));
206 assert!(file.exists());
207 }
208
209 #[test]
210 fn missing_dir_created() {
211 let dir = tempfile::tempdir().unwrap();
212 let sub = dir.path().join("sub/dir");
213 cleanup_overflow_files_in(&sub, Duration::from_secs(86_400));
214 assert!(sub.exists());
215 }
216
217 #[test]
218 #[cfg(unix)]
219 fn cleanup_skips_symlinks() {
220 let dir = tempfile::tempdir().unwrap();
221 let outside_dir = tempfile::tempdir().unwrap();
223 let target = outside_dir.path().join("target.txt");
224 std::fs::write(&target, "data").unwrap();
225
226 let link = dir.path().join("link.txt");
228 std::os::unix::fs::symlink(&target, &link).unwrap();
229
230 let old_time = SystemTime::now() - Duration::from_secs(86_500);
232 let ft = filetime::FileTime::from_system_time(old_time);
233 filetime::set_file_mtime(&link, ft).unwrap();
234
235 cleanup_overflow_files_in(dir.path(), Duration::from_secs(86_400));
236
237 assert!(link.exists(), "symlink should not be removed by cleanup");
239 assert!(target.exists(), "symlink target must not be removed");
241 }
242
243 #[test]
244 fn cleanup_uses_retention_days() {
245 let dir = tempfile::tempdir().unwrap();
246 let file = dir.path().join("stale.txt");
247 std::fs::write(&file, "data").unwrap();
248 let old_time = SystemTime::now() - Duration::from_secs(7 * 86_400 + 100);
249 let ft = filetime::FileTime::from_system_time(old_time);
250 filetime::set_file_mtime(&file, ft).unwrap();
251
252 let config = OverflowConfig {
253 threshold: 50_000,
254 retention_days: 7,
255 dir: Some(dir.path().to_path_buf()),
256 };
257 cleanup_overflow_files(&config);
258 assert!(!file.exists());
259 }
260
261 #[test]
262 fn default_config_values() {
263 let config = OverflowConfig::default();
264 assert_eq!(config.threshold, 50_000);
265 assert_eq!(config.retention_days, 7);
266 assert!(config.dir.is_none());
267 }
268}