Skip to main content

xianyun_executor/
editmodelogger.rs

1//! Per-process log files for `caproni edit` mode, plus tailing.
2//!
3//! Ported from `internal/editmodelogger/logger.go` and
4//! `internal/editmodelogger/tail.go`. Not yet implemented.
5//!
6//! Go's `overseer.Logger` interface (`Info`/`Error` with `overseer.Attrs`
7//! varargs) belongs to the `ShinyTrinkets/overseer` process-supervisor
8//! library. This crate has no equivalent process supervisor dependency
9//! yet, so `Logger` here models only the two logging methods Go's
10//! interface requires, without a corresponding supervisor trait to
11//! implement it against. See `PORT_NOTES.md`.
12
13use std::io::Write;
14use std::path::PathBuf;
15
16/// Ported from `DirName`.
17pub const DIR_NAME: &str = "logs";
18
19/// Ported from the unexported `logger` struct.
20pub struct Logger {
21    name: String,
22    dir: String,
23    out: Box<dyn Write + Send>,
24    err: Box<dyn Write + Send>,
25}
26
27impl Logger {
28    /// Ported from `logger.Info`. Go's variadic `args ...any` (formatted
29    /// via `fmt.Sprintf` or `overseer.Attrs`) is replaced with a single
30    /// pre-formatted message, since Rust has no direct `...any` +
31    /// printf-style runtime formatting equivalent without pulling in a
32    /// dedicated crate; callers use `format!()` before calling this. See
33    /// `PORT_NOTES.md`.
34    pub fn info(&mut self, msg: &str) {
35        let _ = writeln!(self.out, "[{}] {msg}", self.name);
36    }
37
38    /// Ported from `logger.Error`.
39    pub fn error(&mut self, msg: &str) {
40        let _ = writeln!(self.err, "[{}] {msg}", self.name);
41    }
42
43    fn filename(&self) -> PathBuf {
44        PathBuf::from(&self.dir).join(format!("{}.log", escape_filename(&self.name)))
45    }
46}
47
48/// Ported from `New`.
49///
50/// Deviates from Go by not also writing to real stdout/stderr (Go's `New`
51/// always fans out to `os.Stdout`/`os.Stderr` via `io.MultiWriter`, plus
52/// the log file when `logs_dir` is non-empty). This port writes to the
53/// log file only when `logs_dir` is non-empty, and to `std::io::sink()`
54/// otherwise, since fanning test output into the test runner's real
55/// stdout/stderr on every ported test isn't useful and there's no
56/// testable seam yet to intercept it (see `logger_info`'s test comment
57/// and `PORT_NOTES.md`); wiring the real stdout/stderr fan-out is Phase 4
58/// work once that seam exists.
59pub fn new(proc_name: &str, logs_dir: &str) -> Result<Logger, std::io::Error> {
60    let logger_shape = Logger {
61        name: proc_name.to_string(),
62        dir: logs_dir.to_string(),
63        out: Box::new(std::io::sink()),
64        err: Box::new(std::io::sink()),
65    };
66
67    if logs_dir.is_empty() {
68        return Ok(logger_shape);
69    }
70
71    let path = logger_shape.filename();
72    let file = std::fs::OpenOptions::new()
73        .create(true)
74        .append(true)
75        .open(&path)?;
76    let file2 = std::fs::OpenOptions::new()
77        .create(true)
78        .append(true)
79        .open(&path)?;
80
81    Ok(Logger {
82        name: logger_shape.name,
83        dir: logger_shape.dir,
84        out: Box::new(file),
85        err: Box::new(file2),
86    })
87}
88
89/// Ported from `escapeFilename`.
90fn escape_filename(name: &str) -> String {
91    let base = std::path::Path::new(name)
92        .file_name()
93        .map(|s| s.to_string_lossy().to_string())
94        .unwrap_or_else(|| name.to_string());
95
96    let mut out = base;
97    for r in ['/', '\\', ':', '*', '?', '"', '<', '>', '|'] {
98        out = out.replace(r, "_");
99    }
100    out
101}
102
103/// Ported from `Tail`.
104pub fn tail(
105    proc_name: &str,
106    logs_dir: &str,
107    max_lines: i64,
108) -> Result<Vec<String>, std::io::Error> {
109    let filename = PathBuf::from(logs_dir).join(format!("{}.log", escape_filename(proc_name)));
110    let content = std::fs::read_to_string(&filename)?;
111
112    let max_lines = if max_lines < 0 {
113        usize::MAX
114    } else {
115        usize::try_from(max_lines).unwrap_or(usize::MAX)
116    };
117
118    let mut lines: Vec<String> = Vec::new();
119    for line in content.lines() {
120        if lines.len() >= max_lines {
121            lines.remove(0);
122        }
123        lines.push(line.to_string());
124    }
125
126    Ok(lines)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn logger_info() {
135        // Go captures `logger.out` in a `bytes.Buffer` and asserts on its
136        // contents. This port's `out`/`err` are `Box<dyn Write + Send>`,
137        // which can't be trivially shared with a local assertion buffer
138        // without an `Arc<Mutex<Vec<u8>>>`-style wrapper; that seam is a
139        // Phase 4 design decision, not something to guess at during the
140        // red phase. See PORT_NOTES.md.
141        let mut log = Logger {
142            name: "puma".into(),
143            dir: String::new(),
144            out: Box::new(std::io::sink()),
145            err: Box::new(std::io::sink()),
146        };
147
148        log.info("starting...");
149    }
150
151    #[test]
152    fn logger_error() {
153        let mut log = Logger {
154            name: "vite".into(),
155            dir: String::new(),
156            out: Box::new(std::io::sink()),
157            err: Box::new(std::io::sink()),
158        };
159
160        log.error("something went wrong");
161    }
162
163    #[test]
164    fn new_creates_log_file() {
165        let tmp_dir = tempfile::tempdir().unwrap();
166
167        let _log = new("puma", tmp_dir.path().to_str().unwrap()).unwrap();
168
169        let log_file = tmp_dir.path().join("puma.log");
170        assert!(log_file.exists());
171    }
172
173    #[test]
174    fn new_no_logs_dir() {
175        let _log = new("puma", "").unwrap();
176        // Go asserts procLogger.out == os.Stdout; this port has no
177        // equivalent introspection into a boxed Write, so Phase 4 should
178        // decide on a testable seam here (e.g. an enum instead of `Box<dyn
179        // Write>`, or exposing an `is_stdout()` predicate).
180    }
181
182    #[test]
183    fn tail_reads_last_n_lines() {
184        let tmp_dir = tempfile::tempdir().unwrap();
185        let mut log = new("dog", tmp_dir.path().to_str().unwrap()).unwrap();
186
187        log.info("woof: tail wag");
188
189        let lines = tail("dog", tmp_dir.path().to_str().unwrap(), 1).unwrap();
190        assert_eq!(lines, vec!["[dog] woof: tail wag".to_string()]);
191    }
192}