Skip to main content

stryke/
stryke_log.rs

1//! Stryke long-lived log file at `~/.stryke/stryke.log` (or
2//! `$STRYKE_HOME/stryke.log` when that env var is set).
3//!
4//! Both the LSP server (`stryke --lsp`) and the DAP server (`stryke --dap`)
5//! emit milestone events here so plugin / IDE issues can be diagnosed by
6//! `tail -f ~/.stryke/stryke.log` without having to attach a debugger or
7//! re-launch with `RUST_LOG`. Keep messages terse — one line per event,
8//! self-describing, no chatter inside hot loops above DEBUG level.
9//!
10//! ## Levels
11//!
12//! `TRACE` < `DEBUG` < `INFO` < `WARN` < `ERROR`. Compile-time filtering
13//! happens at the macro site (the `format!` doesn't run when the level is
14//! gated out). Runtime threshold from `$STRYKE_LOG_LEVEL` (case-insensitive,
15//! default `INFO`).
16//!
17//! Use the macros, not the bare functions:
18//!
19//! ```ignore
20//! crate::slog_info!("lsp", "started pid={}", pid);
21//! crate::slog_debug!("lsp.rename", "files={} edits={}", n_files, n_edits);
22//! crate::slog_warn!("dap", "tcp connect {} failed: {}", addr, err);
23//! ```
24//!
25//! Honors `$STRYKE_LOG_FILE` for tests / one-off redirection. The user-
26//! facing convention is `~/.stryke/stryke.log`; the env override exists for
27//! sandboxed runs only.
28//!
29//! Failure to open / write the log MUST NOT crash the server — log
30//! writes are best-effort.
31
32use std::fs::OpenOptions;
33use std::io::Write;
34use std::path::PathBuf;
35use std::sync::Mutex;
36use std::sync::OnceLock;
37use std::time::{SystemTime, UNIX_EPOCH};
38
39static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
40static LEVEL: OnceLock<Level> = OnceLock::new();
41
42/// Default rotation threshold per file (5 MB) — small enough that a long-
43/// running daemon never accumulates a multi-GB log, large enough that
44/// rotation doesn't churn during normal day-to-day editor traffic.
45const DEFAULT_MAX_BYTES: u64 = 5 * 1024 * 1024;
46/// Number of rotated generations to keep (`stryke.log.1` … `stryke.log.5`).
47/// The active file plus 5 rotations bounds the log dir at ~30 MB by default.
48const DEFAULT_MAX_FILES: u32 = 5;
49
50#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
51pub enum Level {
52    Trace = 0,
53    Debug = 1,
54    Info = 2,
55    Warn = 3,
56    Error = 4,
57}
58
59impl Level {
60    pub fn as_str(self) -> &'static str {
61        match self {
62            Level::Trace => "TRACE",
63            Level::Debug => "DEBUG",
64            Level::Info => "INFO",
65            Level::Warn => "WARN",
66            Level::Error => "ERROR",
67        }
68    }
69
70    /// Parse a level name. Accepts any case (`info`/`Info`/`INFO`).
71    /// Anything unknown returns `None` so the caller can decide on a fallback.
72    pub fn parse(s: &str) -> Option<Level> {
73        match s.trim().to_ascii_lowercase().as_str() {
74            "trace" => Some(Level::Trace),
75            "debug" => Some(Level::Debug),
76            "info" => Some(Level::Info),
77            "warn" | "warning" => Some(Level::Warn),
78            "error" | "err" => Some(Level::Error),
79            _ => None,
80        }
81    }
82}
83
84/// Resolved log path. Order:
85/// 1. `$STRYKE_LOG_FILE` — explicit override.
86/// 2. `$STRYKE_HOME/stryke.log` — when `$STRYKE_HOME` is set.
87/// 3. `~/.stryke/stryke.log` — default.
88pub fn log_path() -> PathBuf {
89    if let Ok(p) = std::env::var("STRYKE_LOG_FILE") {
90        if !p.is_empty() {
91            return PathBuf::from(p);
92        }
93    }
94    if let Ok(h) = std::env::var("STRYKE_HOME") {
95        if !h.is_empty() {
96            return PathBuf::from(h).join("stryke.log");
97        }
98    }
99    let home = std::env::var("HOME").unwrap_or_default();
100    PathBuf::from(home).join(".stryke").join("stryke.log")
101}
102
103/// Current minimum level. Reads `$STRYKE_LOG_LEVEL` the first time it's
104/// asked, then caches. Default = `INFO`. Tests that need to flip the level
105/// must do it via [`force_level_for_test`] (the env var is read once).
106pub fn current_level() -> Level {
107    *LEVEL.get_or_init(|| {
108        std::env::var("STRYKE_LOG_LEVEL")
109            .ok()
110            .and_then(|s| Level::parse(&s))
111            .unwrap_or(Level::Info)
112    })
113}
114
115/// Per-file rotation threshold in bytes. `0` disables rotation (the active
116/// file grows unbounded). From `$STRYKE_LOG_MAX_BYTES`; default 5 MB.
117///
118/// Not cached — the env var is re-read on every call so tests and runtime
119/// reconfiguration both work. The cost is one `env::var` per rotation check,
120/// which is negligible next to the `fs::metadata` call rotation already
121/// performs.
122pub fn max_bytes() -> u64 {
123    std::env::var("STRYKE_LOG_MAX_BYTES")
124        .ok()
125        .and_then(|s| s.parse::<u64>().ok())
126        .unwrap_or(DEFAULT_MAX_BYTES)
127}
128
129/// Maximum number of rotated generations to retain. The active file plus N
130/// archives. From `$STRYKE_LOG_MAX_FILES`; default 5. Not cached (see
131/// [`max_bytes`] for rationale).
132pub fn max_files() -> u32 {
133    std::env::var("STRYKE_LOG_MAX_FILES")
134        .ok()
135        .and_then(|s| s.parse::<u32>().ok())
136        .filter(|&n| n >= 1)
137        .unwrap_or(DEFAULT_MAX_FILES)
138}
139
140/// Rotate `path` if its current size meets or exceeds the configured
141/// threshold. Renames `<path>.N-1` → `<path>.N` (oldest first), then
142/// `<path>` → `<path>.1`. The drop of `<path>.MAX` is implicit — the next
143/// shift overwrites it. No-op when the file doesn't exist, when rotation
144/// is disabled (`max_bytes == 0`), or when the size is under threshold.
145///
146/// Called from inside [`log_at`] under the global write lock, so cross-
147/// thread races between rotation and append can't interleave.
148fn rotate_if_needed(path: &std::path::Path) {
149    let max = max_bytes();
150    if max == 0 {
151        return;
152    }
153    let size = match std::fs::metadata(path) {
154        Ok(md) => md.len(),
155        Err(_) => return,
156    };
157    if size < max {
158        return;
159    }
160    let n = max_files();
161    let base = path.as_os_str().to_string_lossy().into_owned();
162    // Drop the eldest by renaming inward — `.N-1 → .N` first, walking down
163    // to `.1 → .2`, then `<path> → .1`. The eldest is overwritten in
164    // place by the rename, which is what bounds the total file count.
165    for i in (1..n).rev() {
166        let from = format!("{base}.{i}");
167        let to = format!("{base}.{}", i + 1);
168        let _ = std::fs::rename(&from, &to);
169    }
170    let dot1 = format!("{base}.1");
171    let _ = std::fs::rename(path, &dot1);
172}
173
174/// True if a message at `lvl` would be written. Use to short-circuit
175/// expensive `format!` arguments at the call site — the macros do this for
176/// you.
177#[inline]
178pub fn enabled(lvl: Level) -> bool {
179    lvl >= current_level()
180}
181
182/// Append a level-stamped line to the log. Most callers should use the
183/// `slog_*!` macros instead so the message argument is only formatted when
184/// the level is enabled.
185pub fn log_at(lvl: Level, tag: &str, msg: &str) {
186    if !enabled(lvl) {
187        return;
188    }
189    let mu = LOCK.get_or_init(|| Mutex::new(()));
190    let _g = mu.lock().unwrap_or_else(|p| p.into_inner());
191    let path = log_path();
192    if let Some(dir) = path.parent() {
193        let _ = std::fs::create_dir_all(dir);
194    }
195    // Rotation check sits inside the write lock so two threads can't
196    // concurrently rotate or write past the threshold.
197    rotate_if_needed(&path);
198    let ts = SystemTime::now()
199        .duration_since(UNIX_EPOCH)
200        .map(|d| d.as_millis())
201        .unwrap_or(0);
202    let secs = (ts / 1000) as i64;
203    let millis = (ts % 1000) as u32;
204    if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
205        let _ = writeln!(
206            f,
207            "[{}.{:03}] [{:>5}] [{}] {}",
208            secs,
209            millis,
210            lvl.as_str(),
211            tag,
212            msg
213        );
214    }
215}
216
217/// Back-compat alias: legacy callers that don't carry a level. Treated as
218/// `INFO`. Prefer the leveled macros (`slog_info!`, etc.) for new code.
219pub fn log(tag: &str, msg: &str) {
220    log_at(Level::Info, tag, msg);
221}
222
223/// Test-only level override. Bypasses the cached `OnceLock` by writing to a
224/// secondary atomic. Kept simple — production code path doesn't touch this.
225#[cfg(test)]
226pub fn force_level_for_test(_lvl: Level) {
227    // The OnceLock makes runtime level changes a no-op in production. Tests
228    // exercise `log_at` directly with the level parameter when they need
229    // deterministic filtering, so this is intentionally a stub.
230}
231
232/// `slog_trace!("tag", "fmt {} {}", a, b);`
233#[macro_export]
234macro_rules! slog_trace {
235    ($tag:expr, $($arg:tt)*) => {{
236        if $crate::stryke_log::enabled($crate::stryke_log::Level::Trace) {
237            $crate::stryke_log::log_at(
238                $crate::stryke_log::Level::Trace,
239                $tag,
240                &format!($($arg)*),
241            );
242        }
243    }};
244}
245
246#[macro_export]
247macro_rules! slog_debug {
248    ($tag:expr, $($arg:tt)*) => {{
249        if $crate::stryke_log::enabled($crate::stryke_log::Level::Debug) {
250            $crate::stryke_log::log_at(
251                $crate::stryke_log::Level::Debug,
252                $tag,
253                &format!($($arg)*),
254            );
255        }
256    }};
257}
258
259#[macro_export]
260macro_rules! slog_info {
261    ($tag:expr, $($arg:tt)*) => {{
262        if $crate::stryke_log::enabled($crate::stryke_log::Level::Info) {
263            $crate::stryke_log::log_at(
264                $crate::stryke_log::Level::Info,
265                $tag,
266                &format!($($arg)*),
267            );
268        }
269    }};
270}
271
272#[macro_export]
273macro_rules! slog_warn {
274    ($tag:expr, $($arg:tt)*) => {{
275        if $crate::stryke_log::enabled($crate::stryke_log::Level::Warn) {
276            $crate::stryke_log::log_at(
277                $crate::stryke_log::Level::Warn,
278                $tag,
279                &format!($($arg)*),
280            );
281        }
282    }};
283}
284
285#[macro_export]
286macro_rules! slog_error {
287    ($tag:expr, $($arg:tt)*) => {{
288        if $crate::stryke_log::enabled($crate::stryke_log::Level::Error) {
289            $crate::stryke_log::log_at(
290                $crate::stryke_log::Level::Error,
291                $tag,
292                &format!($($arg)*),
293            );
294        }
295    }};
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use std::sync::Mutex as StdMutex;
302
303    /// Tests in this module mutate process-wide env vars
304    /// (`STRYKE_LOG_FILE`, `STRYKE_HOME`, `STRYKE_LOG_MAX_*`). Cargo runs
305    /// tests in parallel by default, so we serialize through a module-level
306    /// mutex — every test that touches env state must hold this guard for
307    /// its duration. Without it, `STRYKE_LOG_FILE` set by one test leaks
308    /// into `log_path_honors_stryke_home`'s assertion etc.
309    static ENV_GUARD: StdMutex<()> = StdMutex::new(());
310
311    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
312        ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner())
313    }
314
315    fn clear_log_env() {
316        std::env::remove_var("STRYKE_LOG_FILE");
317        std::env::remove_var("STRYKE_HOME");
318        std::env::remove_var("STRYKE_LOG_MAX_BYTES");
319        std::env::remove_var("STRYKE_LOG_MAX_FILES");
320    }
321
322    fn fresh_path() -> PathBuf {
323        std::env::temp_dir().join(format!(
324            "stryke-log-{}-{}.log",
325            std::process::id(),
326            SystemTime::now()
327                .duration_since(UNIX_EPOCH)
328                .map(|d| d.as_nanos())
329                .unwrap_or(0)
330        ))
331    }
332
333    #[test]
334    fn log_writes_to_env_override() {
335        let _g = env_lock();
336        clear_log_env();
337        let tmp = fresh_path();
338        std::env::set_var("STRYKE_LOG_FILE", &tmp);
339        log("test", "hello world");
340        let contents = std::fs::read_to_string(&tmp).expect("log file written");
341        assert!(
342            contents.contains("[ INFO] [test] hello world"),
343            "got: {contents:?}"
344        );
345        let _ = std::fs::remove_file(&tmp);
346        clear_log_env();
347    }
348
349    #[test]
350    fn log_path_honors_stryke_home() {
351        let _g = env_lock();
352        clear_log_env();
353        std::env::set_var("STRYKE_HOME", "/tmp/stryke-home-fixture");
354        let p = log_path();
355        assert_eq!(p, PathBuf::from("/tmp/stryke-home-fixture/stryke.log"));
356        clear_log_env();
357    }
358
359    #[test]
360    fn level_parsing_accepts_canonical_names() {
361        assert_eq!(Level::parse("trace"), Some(Level::Trace));
362        assert_eq!(Level::parse("DEBUG"), Some(Level::Debug));
363        assert_eq!(Level::parse("Info"), Some(Level::Info));
364        assert_eq!(Level::parse("warning"), Some(Level::Warn));
365        assert_eq!(Level::parse("err"), Some(Level::Error));
366        assert_eq!(Level::parse("zorp"), None);
367    }
368
369    #[test]
370    fn level_ordering_matches_severity() {
371        assert!(Level::Trace < Level::Debug);
372        assert!(Level::Debug < Level::Info);
373        assert!(Level::Info < Level::Warn);
374        assert!(Level::Warn < Level::Error);
375    }
376
377    #[test]
378    fn rotate_shifts_files_inward_when_oversize() {
379        let _g = env_lock();
380        clear_log_env();
381        std::env::set_var("STRYKE_LOG_MAX_BYTES", "10");
382        std::env::set_var("STRYKE_LOG_MAX_FILES", "3");
383        let p = fresh_path();
384        std::fs::write(&p, b"AAAAAAAAAAAAAAAA").unwrap(); // 16 bytes > 10
385                                                          // Pre-existing rotations: simulate one previous shift so we can
386                                                          // verify the inward walk.
387        std::fs::write(format!("{}.1", p.display()), b"prev").unwrap();
388        rotate_if_needed(&p);
389        // Active file is gone (renamed to .1).
390        assert!(!p.exists(), "active path must be rotated away");
391        // .1 must now contain the old active body.
392        let r1 = std::fs::read(format!("{}.1", p.display())).unwrap();
393        assert_eq!(r1, b"AAAAAAAAAAAAAAAA");
394        // The previous .1 should have moved to .2.
395        let r2 = std::fs::read(format!("{}.2", p.display())).unwrap();
396        assert_eq!(r2, b"prev");
397        let _ = std::fs::remove_file(&p);
398        let _ = std::fs::remove_file(format!("{}.1", p.display()));
399        let _ = std::fs::remove_file(format!("{}.2", p.display()));
400        clear_log_env();
401    }
402
403    #[test]
404    fn rotate_noop_when_disabled() {
405        let _g = env_lock();
406        clear_log_env();
407        std::env::set_var("STRYKE_LOG_MAX_BYTES", "0");
408        let p = fresh_path();
409        std::fs::write(&p, b"this is way more than zero bytes long").unwrap();
410        rotate_if_needed(&p);
411        assert!(p.exists(), "rotation disabled — file must persist");
412        let _ = std::fs::remove_file(&p);
413        clear_log_env();
414    }
415
416    #[test]
417    fn rotate_noop_when_under_threshold() {
418        let _g = env_lock();
419        clear_log_env();
420        std::env::set_var("STRYKE_LOG_MAX_BYTES", "1000");
421        let p = fresh_path();
422        std::fs::write(&p, b"short").unwrap();
423        rotate_if_needed(&p);
424        assert!(p.exists(), "small file must not rotate");
425        let _ = std::fs::remove_file(&p);
426        clear_log_env();
427    }
428
429    #[test]
430    fn log_at_writes_level_in_line() {
431        let _g = env_lock();
432        clear_log_env();
433        let tmp = fresh_path();
434        std::env::set_var("STRYKE_LOG_FILE", &tmp);
435        // ERROR ≥ default INFO so the level threshold doesn't filter this.
436        log_at(Level::Error, "boot", "fatal=42");
437        let contents = std::fs::read_to_string(&tmp).expect("written");
438        assert!(
439            contents.contains("[ERROR] [boot] fatal=42"),
440            "got: {contents:?}"
441        );
442        let _ = std::fs::remove_file(&tmp);
443        clear_log_env();
444    }
445
446    #[test]
447    fn rotation_kicks_in_during_real_writes() {
448        // End-to-end: write enough that the rotation threshold fires
449        // mid-stream. Verifies log_at + rotate_if_needed integration.
450        let _g = env_lock();
451        clear_log_env();
452        let tmp = fresh_path();
453        std::env::set_var("STRYKE_LOG_FILE", &tmp);
454        std::env::set_var("STRYKE_LOG_MAX_BYTES", "100");
455        std::env::set_var("STRYKE_LOG_MAX_FILES", "2");
456        for i in 0..50 {
457            log_at(
458                Level::Error,
459                "stress",
460                &format!("line-{:04}-filler-text-here", i),
461            );
462        }
463        let dot1 = std::path::PathBuf::from(format!("{}.1", tmp.display()));
464        assert!(dot1.exists(), "rotation must have produced a .1 archive");
465        // Active log must be smaller than the sum of all writes.
466        let active_size = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0);
467        assert!(
468            active_size < 50 * 30,
469            "active log {active_size} should be smaller than total write volume"
470        );
471        let _ = std::fs::remove_file(&tmp);
472        let _ = std::fs::remove_file(&dot1);
473        let _ = std::fs::remove_file(format!("{}.2", tmp.display()));
474        clear_log_env();
475    }
476}