1use std::fs::{self, File, OpenOptions};
27use std::io::Write;
28use std::path::{Path, PathBuf};
29
30use crate::excerpt::Accumulator;
31
32const KEEP: usize = 10;
35
36pub enum Stored {
38 Saved(PathBuf),
39 RenameFailed(PathBuf),
41 NotWritten(String),
42}
43
44impl Stored {
45 pub fn note(&self) -> Option<String> {
48 match self {
49 Stored::Saved(_) => None,
50 Stored::RenameFailed(tmp) => Some(format!("log left at {}", tmp.display())),
51 Stored::NotWritten(why) => Some(format!("log not written: {why}")),
52 }
53 }
54
55 pub fn path(&self) -> Option<&Path> {
57 match self {
58 Stored::Saved(p) | Stored::RenameFailed(p) => Some(p),
59 Stored::NotWritten(_) => None,
60 }
61 }
62}
63
64pub struct Capture {
65 pub tmp: PathBuf,
66 done: PathBuf,
67 file: Option<File>,
68 err: Option<String>,
69 written: u64,
70 pub seen: u64,
72 #[cfg(feature = "test-hooks")]
73 fail_after: Option<u64>,
74 pub acc: Accumulator,
75 entry: Option<amont_quiet::Entry>,
78}
79
80fn repo() -> &'static str {
83 static TOP: std::sync::OnceLock<String> = std::sync::OnceLock::new();
84 TOP.get_or_init(|| {
85 crate::git::stdout(&["rev-parse", "--show-toplevel"]).unwrap_or_else(|| {
86 std::env::current_dir()
87 .map(|d| d.display().to_string())
88 .unwrap_or_default()
89 })
90 })
91}
92
93pub fn log_dir() -> Option<PathBuf> {
95 let git_dir = crate::git::stdout(&["rev-parse", "--absolute-git-dir"])?;
96 Some(PathBuf::from(git_dir).join("amont").join("logs"))
97}
98
99impl Capture {
100 pub fn open(dir: Option<PathBuf>, check: &str) -> Capture {
103 #[cfg(feature = "test-hooks")]
104 let fail_after = std::env::var("AMONT_TEST_LOG_FAIL_AFTER")
105 .ok()
106 .and_then(|v| v.parse().ok());
107 #[cfg(not(feature = "test-hooks"))]
108 let fail_after = None;
109 Capture::open_failing_after(dir, check, fail_after)
110 }
111
112 pub fn open_failing_after(
115 dir: Option<PathBuf>,
116 check: &str,
117 #[cfg_attr(not(feature = "test-hooks"), allow(unused_variables))] fail_after: Option<u64>,
118 ) -> Capture {
119 let name = file_name(check);
120 let pid = std::process::id();
121 let (tmp, done, file, err) = match dir {
122 None => (
123 PathBuf::new(),
124 PathBuf::new(),
125 None,
126 Some("not inside a git repository".to_string()),
127 ),
128 Some(dir) => {
129 let done = dir.join(format!("{name}.{pid}.log"));
130 let tmp = dir.join(format!("{name}.{pid}.log.tmp"));
131 match create(&dir, &tmp) {
132 Ok(f) => (tmp, done, Some(f), None),
133 Err(e) => (tmp, done, None, Some(format!("cannot create the log: {e}"))),
134 }
135 }
136 };
137 let session = std::env::var("CLAUDE_CODE_SESSION_ID").unwrap_or_default();
139 let entry = file.as_ref().and_then(|_| {
140 amont_quiet::Entry::start(
141 &amont_quiet::dir_from_env()?,
142 "amont",
143 &session,
144 check,
145 repo,
146 &tmp,
147 )
148 });
149 Capture {
150 tmp,
151 done,
152 file,
153 err,
154 written: 0,
155 seen: 0,
156 #[cfg(feature = "test-hooks")]
157 fail_after,
158 acc: Accumulator::new(crate::excerpt::DEFAULT_BOUND),
159 entry,
160 }
161 }
162
163 pub fn end_entry(&mut self, exit: Option<i32>, log: Option<&Path>) {
167 if let Some(entry) = self.entry.as_mut() {
168 entry.end(exit, log);
169 }
170 }
171
172 pub fn writing(&self) -> bool {
174 self.file.is_some()
175 }
176
177 pub fn take(&mut self, bytes: &[u8]) {
178 self.seen += bytes.len() as u64;
179 self.acc.feed(bytes);
180 let (chunk, injected) = self.clip(bytes);
181 let Some(file) = self.file.as_mut() else {
182 return;
183 };
184 match file.write_all(chunk) {
185 Ok(()) => self.written += chunk.len() as u64,
186 Err(e) => self.err = Some(format!("write failed: {e}")),
187 }
188 if injected && self.err.is_none() {
189 self.err = Some(format!(
190 "injected write failure after {} bytes",
191 self.written
192 ));
193 }
194 if self.err.is_some() {
195 self.file = None;
197 }
198 }
199
200 #[cfg(feature = "test-hooks")]
201 fn clip<'a>(&self, bytes: &'a [u8]) -> (&'a [u8], bool) {
202 match self.fail_after {
203 Some(limit) => {
204 let left = limit.saturating_sub(self.written) as usize;
205 if left < bytes.len() {
206 (&bytes[..left], true)
207 } else {
208 (bytes, false)
209 }
210 }
211 None => (bytes, false),
212 }
213 }
214
215 #[cfg(not(feature = "test-hooks"))]
216 fn clip<'a>(&self, bytes: &'a [u8]) -> (&'a [u8], bool) {
217 (bytes, false)
218 }
219
220 pub fn finish(&mut self) -> Stored {
222 let file = self.file.take();
223 drop(file);
224 if let Some(why) = self.err.take() {
225 if !self.tmp.as_os_str().is_empty() {
228 let _ = fs::remove_file(&self.tmp);
229 }
230 return Stored::NotWritten(why);
231 }
232 match fs::rename(&self.tmp, &self.done) {
233 Ok(()) => {
234 if let Some(dir) = self.done.parent() {
235 prune(dir, &self.done);
236 }
237 Stored::Saved(self.done.clone())
238 }
239 Err(_) => Stored::RenameFailed(self.tmp.clone()),
240 }
241 }
242}
243
244fn create(dir: &Path, tmp: &Path) -> std::io::Result<File> {
245 fs::create_dir_all(dir)?;
246 let mut open = OpenOptions::new();
247 open.write(true).create(true).truncate(true);
248 #[cfg(unix)]
249 {
250 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
251 let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
252 open.mode(0o600);
253 }
254 open.open(tmp)
255}
256
257fn file_name(check: &str) -> String {
259 let clean: String = check
260 .chars()
261 .map(|c| {
262 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
263 c
264 } else {
265 '_'
266 }
267 })
268 .take(60)
269 .collect();
270 if clean.is_empty() {
271 "check".into()
272 } else {
273 clean
274 }
275}
276
277fn prune(dir: &Path, done: &Path) {
281 let Some(name) = done.file_name().and_then(|n| n.to_str()) else {
282 return;
283 };
284 let Some(stem) = name.strip_suffix(".log").and_then(|s| s.rsplit_once('.')) else {
286 return;
287 };
288 let prefix = format!("{}.", stem.0);
289 let Ok(read) = fs::read_dir(dir) else { return };
290 let mut logs: Vec<(std::time::SystemTime, PathBuf)> = read
291 .filter_map(|e| e.ok())
292 .filter(|e| {
293 let n = e.file_name();
294 let n = n.to_string_lossy();
295 n.starts_with(&prefix)
296 && n.ends_with(".log")
297 && n[prefix.len()..n.len() - 4]
298 .bytes()
299 .all(|b| b.is_ascii_digit())
300 })
301 .filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
302 .collect();
303 logs.sort();
304 let excess = logs.len().saturating_sub(KEEP);
305 for (_, old) in logs.into_iter().take(excess) {
306 let _ = fs::remove_file(old);
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313
314 #[test]
315 fn quiet_entries_never_reach_the_real_state_dir() {
316 assert_eq!(std::env::var("CLAUDE_CODE_SESSION_ID").as_deref(), Ok(""));
319 assert!(std::env::var("QUIET_ACTIVE_DIR").is_ok_and(|d| d.contains("target")));
320 }
321
322 fn scratch(tag: &str) -> PathBuf {
323 let d = std::env::temp_dir().join(format!("amont-capture-{tag}-{}", std::process::id()));
324 let _ = fs::remove_dir_all(&d);
325 d
326 }
327
328 #[test]
329 fn a_tmp_is_renamed_on_finish() {
330 let dir = scratch("rename");
331 let mut c = Capture::open(Some(dir.clone()), "pre-push-cargo-test");
332 assert!(c.tmp.exists());
333 c.take(b"hello\n");
334 let stored = c.finish();
335 let path = stored.path().unwrap().to_path_buf();
336 assert!(path.exists() && !c.tmp.exists());
337 assert_eq!(fs::read_to_string(path).unwrap(), "hello\n");
338 assert!(stored.note().is_none());
339 }
340
341 #[test]
342 fn no_directory_still_gives_an_excerpt() {
343 let mut c = Capture::open(None, "x");
344 c.take(b"thread 'a' panicked at src/a.rs:1:1:\nboom\n");
345 let stored = c.finish();
346 assert!(stored.note().unwrap().starts_with("log not written:"));
347 assert!(c.acc.finish("-").contains("panicked at"));
348 }
349
350 #[test]
351 fn pruning_keeps_ten_and_never_a_tmp_or_another_check() {
352 let dir = scratch("prune");
353 fs::create_dir_all(&dir).unwrap();
354 for i in 0..15 {
355 fs::write(dir.join(format!("clippy.{}.log", 1000 + i)), "x").unwrap();
356 std::thread::sleep(std::time::Duration::from_millis(5));
357 }
358 fs::write(dir.join("clippy.1.log.tmp"), "x").unwrap();
359 fs::write(dir.join("clippy-extra.5.log"), "x").unwrap();
360 let mut c = Capture::open(Some(dir.clone()), "clippy");
361 c.finish();
362 let names: Vec<String> = fs::read_dir(&dir)
363 .unwrap()
364 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
365 .collect();
366 let clippy = names
367 .iter()
368 .filter(|n| n.starts_with("clippy.") && n.ends_with(".log"))
369 .count();
370 assert_eq!(clippy, KEEP, "{names:?}");
371 assert!(names.iter().any(|n| n == "clippy.1.log.tmp"));
372 assert!(names.iter().any(|n| n == "clippy-extra.5.log"));
373 }
374
375 #[cfg(unix)]
376 #[test]
377 fn the_log_is_private() {
378 use std::os::unix::fs::PermissionsExt;
379 let dir = scratch("mode");
380 let c = Capture::open(Some(dir), "x");
381 let mode = fs::metadata(&c.tmp).unwrap().permissions().mode() & 0o777;
382 assert_eq!(mode, 0o600);
383 }
384
385 #[cfg(feature = "test-hooks")]
386 #[test]
387 fn a_failed_write_keeps_the_excerpt() {
388 let dir = scratch("inject");
389 let mut c = Capture::open_failing_after(Some(dir), "x", Some(10));
390 c.take(b"0123456789abcdef\nthread 'a' panicked at src/a.rs:1:1:\n");
391 let stored = c.finish();
392 assert!(stored.note().unwrap().contains("injected write failure"));
393 assert!(!c.tmp.exists());
394 assert!(c.acc.finish("-").contains("panicked at"));
395 }
396
397 #[test]
398 fn a_long_run_keeps_the_accumulator_bounded() {
402 let mut c = Capture::open(None, "x");
403 let chunk: String = (0..1000).map(|i| format!("line {i} ok\n")).collect();
404 let mut fed = 0u64;
405 while fed < 4 * 1024 * 1024 {
406 c.take(chunk.as_bytes());
407 fed += chunk.len() as u64;
408 assert!(c.acc.held() <= 64 * 1024, "{} after {fed}", c.acc.held());
409 }
410 assert_eq!(c.seen, fed);
411 }
412}