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}
76
77pub fn log_dir() -> Option<PathBuf> {
79 let git_dir = crate::git::stdout(&["rev-parse", "--absolute-git-dir"])?;
80 Some(PathBuf::from(git_dir).join("amont").join("logs"))
81}
82
83impl Capture {
84 pub fn open(dir: Option<PathBuf>, check: &str) -> Capture {
87 #[cfg(feature = "test-hooks")]
88 let fail_after = std::env::var("AMONT_TEST_LOG_FAIL_AFTER")
89 .ok()
90 .and_then(|v| v.parse().ok());
91 #[cfg(not(feature = "test-hooks"))]
92 let fail_after = None;
93 Capture::open_failing_after(dir, check, fail_after)
94 }
95
96 pub fn open_failing_after(
99 dir: Option<PathBuf>,
100 check: &str,
101 #[cfg_attr(not(feature = "test-hooks"), allow(unused_variables))] fail_after: Option<u64>,
102 ) -> Capture {
103 let name = file_name(check);
104 let pid = std::process::id();
105 let (tmp, done, file, err) = match dir {
106 None => (
107 PathBuf::new(),
108 PathBuf::new(),
109 None,
110 Some("not inside a git repository".to_string()),
111 ),
112 Some(dir) => {
113 let done = dir.join(format!("{name}.{pid}.log"));
114 let tmp = dir.join(format!("{name}.{pid}.log.tmp"));
115 match create(&dir, &tmp) {
116 Ok(f) => (tmp, done, Some(f), None),
117 Err(e) => (tmp, done, None, Some(format!("cannot create the log: {e}"))),
118 }
119 }
120 };
121 Capture {
122 tmp,
123 done,
124 file,
125 err,
126 written: 0,
127 seen: 0,
128 #[cfg(feature = "test-hooks")]
129 fail_after,
130 acc: Accumulator::new(crate::excerpt::DEFAULT_BOUND),
131 }
132 }
133
134 pub fn writing(&self) -> bool {
136 self.file.is_some()
137 }
138
139 pub fn take(&mut self, bytes: &[u8]) {
140 self.seen += bytes.len() as u64;
141 self.acc.feed(bytes);
142 let (chunk, injected) = self.clip(bytes);
143 let Some(file) = self.file.as_mut() else {
144 return;
145 };
146 match file.write_all(chunk) {
147 Ok(()) => self.written += chunk.len() as u64,
148 Err(e) => self.err = Some(format!("write failed: {e}")),
149 }
150 if injected && self.err.is_none() {
151 self.err = Some(format!(
152 "injected write failure after {} bytes",
153 self.written
154 ));
155 }
156 if self.err.is_some() {
157 self.file = None;
159 }
160 }
161
162 #[cfg(feature = "test-hooks")]
163 fn clip<'a>(&self, bytes: &'a [u8]) -> (&'a [u8], bool) {
164 match self.fail_after {
165 Some(limit) => {
166 let left = limit.saturating_sub(self.written) as usize;
167 if left < bytes.len() {
168 (&bytes[..left], true)
169 } else {
170 (bytes, false)
171 }
172 }
173 None => (bytes, false),
174 }
175 }
176
177 #[cfg(not(feature = "test-hooks"))]
178 fn clip<'a>(&self, bytes: &'a [u8]) -> (&'a [u8], bool) {
179 (bytes, false)
180 }
181
182 pub fn finish(&mut self) -> Stored {
184 let file = self.file.take();
185 drop(file);
186 if let Some(why) = self.err.take() {
187 if !self.tmp.as_os_str().is_empty() {
190 let _ = fs::remove_file(&self.tmp);
191 }
192 return Stored::NotWritten(why);
193 }
194 match fs::rename(&self.tmp, &self.done) {
195 Ok(()) => {
196 if let Some(dir) = self.done.parent() {
197 prune(dir, &self.done);
198 }
199 Stored::Saved(self.done.clone())
200 }
201 Err(_) => Stored::RenameFailed(self.tmp.clone()),
202 }
203 }
204}
205
206fn create(dir: &Path, tmp: &Path) -> std::io::Result<File> {
207 fs::create_dir_all(dir)?;
208 let mut open = OpenOptions::new();
209 open.write(true).create(true).truncate(true);
210 #[cfg(unix)]
211 {
212 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
213 let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
214 open.mode(0o600);
215 }
216 open.open(tmp)
217}
218
219fn file_name(check: &str) -> String {
221 let clean: String = check
222 .chars()
223 .map(|c| {
224 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
225 c
226 } else {
227 '_'
228 }
229 })
230 .take(60)
231 .collect();
232 if clean.is_empty() {
233 "check".into()
234 } else {
235 clean
236 }
237}
238
239fn prune(dir: &Path, done: &Path) {
243 let Some(name) = done.file_name().and_then(|n| n.to_str()) else {
244 return;
245 };
246 let Some(stem) = name.strip_suffix(".log").and_then(|s| s.rsplit_once('.')) else {
248 return;
249 };
250 let prefix = format!("{}.", stem.0);
251 let Ok(read) = fs::read_dir(dir) else { return };
252 let mut logs: Vec<(std::time::SystemTime, PathBuf)> = read
253 .filter_map(|e| e.ok())
254 .filter(|e| {
255 let n = e.file_name();
256 let n = n.to_string_lossy();
257 n.starts_with(&prefix)
258 && n.ends_with(".log")
259 && n[prefix.len()..n.len() - 4]
260 .bytes()
261 .all(|b| b.is_ascii_digit())
262 })
263 .filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
264 .collect();
265 logs.sort();
266 let excess = logs.len().saturating_sub(KEEP);
267 for (_, old) in logs.into_iter().take(excess) {
268 let _ = fs::remove_file(old);
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 fn scratch(tag: &str) -> PathBuf {
277 let d = std::env::temp_dir().join(format!("amont-capture-{tag}-{}", std::process::id()));
278 let _ = fs::remove_dir_all(&d);
279 d
280 }
281
282 #[test]
283 fn a_tmp_is_renamed_on_finish() {
284 let dir = scratch("rename");
285 let mut c = Capture::open(Some(dir.clone()), "pre-push-cargo-test");
286 assert!(c.tmp.exists());
287 c.take(b"hello\n");
288 let stored = c.finish();
289 let path = stored.path().unwrap().to_path_buf();
290 assert!(path.exists() && !c.tmp.exists());
291 assert_eq!(fs::read_to_string(path).unwrap(), "hello\n");
292 assert!(stored.note().is_none());
293 }
294
295 #[test]
296 fn no_directory_still_gives_an_excerpt() {
297 let mut c = Capture::open(None, "x");
298 c.take(b"thread 'a' panicked at src/a.rs:1:1:\nboom\n");
299 let stored = c.finish();
300 assert!(stored.note().unwrap().starts_with("log not written:"));
301 assert!(c.acc.finish("-").contains("panicked at"));
302 }
303
304 #[test]
305 fn pruning_keeps_ten_and_never_a_tmp_or_another_check() {
306 let dir = scratch("prune");
307 fs::create_dir_all(&dir).unwrap();
308 for i in 0..15 {
309 fs::write(dir.join(format!("clippy.{}.log", 1000 + i)), "x").unwrap();
310 std::thread::sleep(std::time::Duration::from_millis(5));
311 }
312 fs::write(dir.join("clippy.1.log.tmp"), "x").unwrap();
313 fs::write(dir.join("clippy-extra.5.log"), "x").unwrap();
314 let mut c = Capture::open(Some(dir.clone()), "clippy");
315 c.finish();
316 let names: Vec<String> = fs::read_dir(&dir)
317 .unwrap()
318 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
319 .collect();
320 let clippy = names
321 .iter()
322 .filter(|n| n.starts_with("clippy.") && n.ends_with(".log"))
323 .count();
324 assert_eq!(clippy, KEEP, "{names:?}");
325 assert!(names.iter().any(|n| n == "clippy.1.log.tmp"));
326 assert!(names.iter().any(|n| n == "clippy-extra.5.log"));
327 }
328
329 #[cfg(unix)]
330 #[test]
331 fn the_log_is_private() {
332 use std::os::unix::fs::PermissionsExt;
333 let dir = scratch("mode");
334 let c = Capture::open(Some(dir), "x");
335 let mode = fs::metadata(&c.tmp).unwrap().permissions().mode() & 0o777;
336 assert_eq!(mode, 0o600);
337 }
338
339 #[cfg(feature = "test-hooks")]
340 #[test]
341 fn a_failed_write_keeps_the_excerpt() {
342 let dir = scratch("inject");
343 let mut c = Capture::open_failing_after(Some(dir), "x", Some(10));
344 c.take(b"0123456789abcdef\nthread 'a' panicked at src/a.rs:1:1:\n");
345 let stored = c.finish();
346 assert!(stored.note().unwrap().contains("injected write failure"));
347 assert!(!c.tmp.exists());
348 assert!(c.acc.finish("-").contains("panicked at"));
349 }
350
351 #[test]
352 fn fifty_megabytes_keep_the_accumulator_bounded() {
353 let mut c = Capture::open(None, "x");
354 let chunk: String = (0..1000).map(|i| format!("line {i} ok\n")).collect();
355 let mut fed = 0u64;
356 while fed < 50 * 1024 * 1024 {
357 c.take(chunk.as_bytes());
358 fed += chunk.len() as u64;
359 }
360 assert!(c.acc.held() <= 64 * 1024, "{}", c.acc.held());
361 assert_eq!(c.seen, fed);
362 }
363}