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