Skip to main content

amont_runtime/
capture.rs

1//! A quiet check's output: the whole of it on disk, a bounded excerpt in
2//! memory (fleet plan 2026-10-11 "test output keeps its status", shared
3//! rules).
4//!
5//! A captured check used to grow `slot.buf` with every byte its tool
6//! printed and pour it all out at the end — a passing clippy was 3.4 KB of
7//! download lines in an agent's context. Under quiet, the bytes go to
8//! `<git-dir>/amont/logs/<check>.<pid>.log.tmp` as they arrive (tailable
9//! while the check runs), and through the excerpt accumulator, which holds
10//! at most 64 KB whatever the tool prints. When the check ends the file is
11//! renamed to `.log`, and the slot prints one line on a pass or the
12//! failure blocks on anything else.
13//!
14//! Storage is never allowed to change the verdict: a log that cannot be
15//! created, written or renamed stops being written, the accumulator keeps
16//! being fed, and the result says where the log is not.
17//!
18//! holds-until: amont installs no SIGINT/SIGTERM handler of its own for
19//! this. `staged_only` already does, to bring back the user's parked
20//! unstaged changes during the index-fidelity hold, and a second handler on
21//! the commit path would compete with it. So an interrupted run leaves its
22//! `<check>.<pid>.log.tmp` — complete up to the interrupt, and named by the
23//! running line — and pruning never removes a `.tmp`. Revisit when amont
24//! has one signal path that both can register with.
25
26use std::fs::{self, File, OpenOptions};
27use std::io::Write;
28use std::path::{Path, PathBuf};
29
30use crate::excerpt::Accumulator;
31
32/// Finished logs kept per check. `*.tmp` (a run in flight, or one a killed
33/// process left) are never pruned.
34const KEEP: usize = 10;
35
36/// Where a check's log ended up.
37pub enum Stored {
38    Saved(PathBuf),
39    /// The run is complete in the `.tmp`; only the rename failed.
40    RenameFailed(PathBuf),
41    NotWritten(String),
42}
43
44impl Stored {
45    /// The line to print after the result, when the log is not where the
46    /// result says it is.
47    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    /// The path a reader can open, if any.
56    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    /// Bytes the tool printed, whether or not the file kept them.
71    pub seen: u64,
72    #[cfg(feature = "test-hooks")]
73    fail_after: Option<u64>,
74    pub acc: Accumulator,
75    /// The active-run entry relais's pane tails this log from, while the run
76    /// is live (see [`amont_quiet`]). `None` when it could not be written.
77    entry: Option<amont_quiet::Entry>,
78}
79
80/// The repository's top level, asked of git once per process: an entry is
81/// written per check, and a pre-commit runs twenty of them.
82fn 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
93/// `<git-dir>/amont/logs`, created 0700. `None` outside a repository.
94pub 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    /// Open `<dir>/<check>.<pid>.log.tmp`. A failure is remembered, not
101    /// returned: the excerpt still works without a file.
102    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    /// [`Capture::open`], with the write failure the tests inject passed in
113    /// (only honoured under the `test-hooks` feature).
114    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        // Only a log that exists can be tailed.
138        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    /// End the active-run entry: `exit` is the check's result (`Some(0)` for
164    /// anything but a failure, `None` when the check died), and `log` where
165    /// the log now is. A capture dropped without this ends with no exit.
166    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    /// Whether a log file is being written.
173    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            // Stop writing; the accumulator keeps being fed.
196            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    /// Close the file and move it into place.
221    pub fn finish(&mut self) -> Stored {
222        let file = self.file.take();
223        drop(file);
224        if let Some(why) = self.err.take() {
225            // A prefix of the run is not the run: do not leave it looking
226            // like a log.
227            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
257/// A check name as a file name: `[A-Za-z0-9._-]`, everything else `_`.
258fn 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
277/// Keep the newest [`KEEP`] finished logs of the check `done` belongs to.
278/// Best-effort: a log that cannot be listed or removed is left for the next
279/// run, and nothing this run prints depends on it.
280fn prune(dir: &Path, done: &Path) {
281    let Some(name) = done.file_name().and_then(|n| n.to_str()) else {
282        return;
283    };
284    // `<check>.<pid>.log` → `<check>.`
285    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        // .cargo/config.toml: an agent running this suite must not put its
317        // fixtures in the person's live relais pane.
318        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    /// Bounded, not merely small at the end: the holding never passes 64 KiB
399    /// at any point of a run 64 times that long. (It fed 50 MiB, checked
400    /// once, and was the slowest unit test by 6 s.)
401    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}