Skip to main content

whycodes_core/
logging.rs

1//! Logging: always-on JSONL, optional file log, crash reports.
2//!
3//! Layout under [`Config::data_dir`]:
4//! ```text
5//! <data_dir>/
6//!   logs/unified.jsonl    # structured events (append-only)
7//!   crash/crash-*.txt     # panic reports
8//!   debug/whycodes-*.log   # --debug / WHYCODES_LOG_FILE human logs
9//!   debug/latest.log      # symlink (or copy) of the active debug log
10//! ```
11//!
12//! Environment:
13//! - `RUST_LOG` / `WHYCODES_LOG_LEVEL` — filter (e.g. `debug`, `info,whycodes_agent=debug`)
14//! - `WHYCODES_LOG_FILE` — extra human-readable log path (verbatim)
15//! - `RUST_BACKTRACE` — included in crash reports when set
16
17use std::fs::{self, File, OpenOptions};
18use std::io::{self, Write};
19use std::path::{Path, PathBuf};
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::{Arc, Mutex, OnceLock};
22
23use chrono::Utc;
24use serde::Serialize;
25use serde_json::json;
26use tracing::{Event, Subscriber};
27use tracing_subscriber::fmt::format::FmtSpan;
28use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
29use tracing_subscriber::util::SubscriberInitExt;
30use tracing_subscriber::{EnvFilter, Registry};
31
32/// Process-wide logging state after a successful [`init`].
33static STATE: OnceLock<LoggingState> = OnceLock::new();
34
35/// Optional terminal / TUI cleanup run from the panic hook (raw mode, alt screen).
36pub(crate) static PANIC_CLEANUP: Mutex<Option<Arc<dyn Fn() + Send + Sync>>> = Mutex::new(None);
37
38/// Whether [`init`] has completed successfully.
39static INITIALIZED: AtomicBool = AtomicBool::new(false);
40
41/// Package version embedded in every JSONL line.
42pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION");
43
44// ── Paths ──────────────────────────────────────────────────────────────────
45
46/// Standard log / crash / debug directories under the data dir.
47#[derive(Debug, Clone)]
48pub struct LogDirs {
49    pub root: PathBuf,
50    pub logs: PathBuf,
51    pub crash: PathBuf,
52    pub debug: PathBuf,
53}
54
55impl LogDirs {
56    pub fn from_data_dir(data_dir: impl Into<PathBuf>) -> Self {
57        let root = data_dir.into();
58        Self {
59            logs: root.join("logs"),
60            crash: root.join("crash"),
61            debug: root.join("debug"),
62            root,
63        }
64    }
65
66    /// Create `logs/`, `crash/`, and `debug/` if missing.
67    pub fn ensure(&self) -> io::Result<()> {
68        fs::create_dir_all(&self.logs)?;
69        fs::create_dir_all(&self.crash)?;
70        fs::create_dir_all(&self.debug)?;
71        Ok(())
72    }
73
74    pub fn unified_jsonl(&self) -> PathBuf {
75        self.logs.join("unified.jsonl")
76    }
77
78    pub fn crash_report_path(&self, stamp: &str) -> PathBuf {
79        self.crash.join(format!("crash-{stamp}.txt"))
80    }
81}
82
83// ── JSONL event ────────────────────────────────────────────────────────────
84
85/// One line of `logs/unified.jsonl`.
86#[derive(Debug, Clone, Serialize)]
87pub struct LogEvent {
88    pub ts: String,
89    pub src: String,
90    pub pid: u32,
91    pub ver: String,
92    pub lvl: String,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub sid: Option<String>,
95    pub msg: String,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub ctx: Option<serde_json::Value>,
98}
99
100impl LogEvent {
101    pub fn new(src: impl Into<String>, lvl: impl Into<String>, msg: impl Into<String>) -> Self {
102        Self {
103            ts: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
104            src: src.into(),
105            pid: std::process::id(),
106            ver: VERSION.to_string(),
107            lvl: lvl.into(),
108            sid: None,
109            msg: msg.into(),
110            ctx: None,
111        }
112    }
113
114    pub fn with_sid(mut self, sid: impl Into<String>) -> Self {
115        self.sid = Some(sid.into());
116        self
117    }
118
119    pub fn with_ctx(mut self, ctx: serde_json::Value) -> Self {
120        self.ctx = Some(ctx);
121        self
122    }
123}
124
125/// Append a single JSON line to `path` (creates parent dirs as needed).
126pub fn append_jsonl(path: &Path, event: &LogEvent) -> io::Result<()> {
127    ensure_parent(path)?;
128    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
129    serde_json::to_writer(&mut file, event).map_err(io::Error::other)?;
130    file.write_all(b"\n")?;
131    file.flush()?;
132    Ok(())
133}
134
135pub(crate) fn ensure_parent(path: &Path) -> io::Result<()> {
136    match path.parent() {
137        Some(parent) => fs::create_dir_all(parent),
138        None => Ok(()),
139    }
140}
141
142/// Emit a structured event to the process-wide `unified.jsonl` (no-op if not init'd).
143pub fn emit(src: &str, lvl: &str, msg: &str, ctx: Option<serde_json::Value>) {
144    emit_to_state(STATE.get(), src, lvl, msg, None, ctx);
145}
146
147/// Like [`emit`] but attaches a session id.
148pub fn emit_sid(
149    src: &str,
150    lvl: &str,
151    msg: &str,
152    sid: Option<&str>,
153    ctx: Option<serde_json::Value>,
154) {
155    emit_to_state(STATE.get(), src, lvl, msg, sid, ctx);
156}
157
158fn emit_to_state(
159    state: Option<&LoggingState>,
160    src: &str,
161    lvl: &str,
162    msg: &str,
163    sid: Option<&str>,
164    ctx: Option<serde_json::Value>,
165) {
166    let Some(state) = state else {
167        return;
168    };
169    let mut event = LogEvent::new(src, lvl, msg);
170    event.pid = state.pid;
171    event.ver = state.version.clone();
172    event.sid = sid.map(|s| s.to_string());
173    event.ctx = ctx;
174    let _ = write_jsonl_locked(&state.unified, &event);
175}
176
177/// Cover the uninitialized skip even after another test has set `STATE`.
178#[cfg(test)]
179pub(crate) fn emit_uninitialized_for_test() {
180    emit_to_state(None, "test", "info", "uninitialized", None, None);
181}
182
183pub(crate) fn write_jsonl_locked(file: &Mutex<File>, event: &LogEvent) -> io::Result<()> {
184    let mut guard = lock_log_file(file)?;
185    serde_json::to_writer(&mut *guard, event).map_err(io::Error::other)?;
186    guard.write_all(b"\n")?;
187    guard.flush()?;
188    Ok(())
189}
190
191pub(crate) fn lock_log_file(file: &Mutex<File>) -> io::Result<std::sync::MutexGuard<'_, File>> {
192    file.lock()
193        .map_err(|e| io::Error::other(format!("log lock poisoned: {e}")))
194}
195
196// ── Init ───────────────────────────────────────────────────────────────────
197
198/// Options for [`init`].
199#[derive(Debug, Clone)]
200pub struct InitOptions {
201    /// Data directory (`Config::data_dir()`).
202    pub data_dir: PathBuf,
203    /// Level / filter string (`info`, `debug`, module filters).
204    pub log_level: Option<String>,
205    /// Optional human log file (`WHYCODES_LOG_FILE`).
206    pub log_file: Option<PathBuf>,
207    /// When true, also write a debug log under `data_dir/debug/`.
208    pub debug: bool,
209    /// Mirror human logs to stderr (disable in full-screen TUI).
210    pub with_stderr: bool,
211}
212
213impl Default for InitOptions {
214    fn default() -> Self {
215        Self {
216            data_dir: PathBuf::from("."),
217            log_level: None,
218            log_file: None,
219            debug: false,
220            with_stderr: true,
221        }
222    }
223}
224
225/// Handle returned by [`init`] (cloneable path info).
226#[derive(Debug, Clone)]
227pub struct LoggingHandle {
228    pub dirs: LogDirs,
229    /// Human-readable debug/log file path if one was opened.
230    pub debug_log: Option<PathBuf>,
231}
232
233struct LoggingState {
234    dirs: LogDirs,
235    unified: Mutex<File>,
236    pid: u32,
237    version: String,
238}
239
240/// Initialize process logging. Safe to call once; subsequent calls return the
241/// existing handle without re-installing the subscriber.
242pub fn init(opts: InitOptions) -> crate::Result<LoggingHandle> {
243    if let Some(state) = STATE.get() {
244        return Ok(LoggingHandle {
245            dirs: state.dirs.clone(),
246            debug_log: None,
247        });
248    }
249
250    let dirs = LogDirs::from_data_dir(&opts.data_dir);
251    dirs.ensure().map_err(crate::Error::Io)?;
252
253    let unified_path = dirs.unified_jsonl();
254    let unified_file = OpenOptions::new()
255        .create(true)
256        .append(true)
257        .open(&unified_path)
258        .map_err(crate::Error::Io)?;
259
260    let pid = std::process::id();
261    let state = LoggingState {
262        dirs: dirs.clone(),
263        unified: Mutex::new(unified_file),
264        pid,
265        version: VERSION.to_string(),
266    };
267
268    // Human-readable log path: explicit env wins, else --debug creates one.
269    let debug_log = resolve_debug_log_path(&dirs, &opts)?;
270
271    let filter = build_env_filter(opts.log_level.as_deref());
272
273    // Build subscriber layers.
274    let jsonl_layer = JsonlLayer {
275        file: Arc::new(Mutex::new(
276            OpenOptions::new()
277                .create(true)
278                .append(true)
279                .open(&unified_path)
280                .map_err(crate::Error::Io)?,
281        )),
282        pid,
283        version: VERSION.to_string(),
284    };
285
286    let registry = Registry::default().with(filter).with(jsonl_layer);
287
288    // Optional file + stderr fmt layers.
289    let file_layer = choose_debug_file(debug_log.as_deref())?.map(fmt_file_layer);
290
291    let stderr_layer = maybe_layer(opts.with_stderr, stderr_fmt_layer());
292
293    // try_init so tests / double-main don't panic
294    let result = registry.with(file_layer).with(stderr_layer).try_init();
295    note_try_init(result.is_ok());
296
297    let _ = STATE.set(state);
298    INITIALIZED.store(true, Ordering::SeqCst);
299
300    install_panic_hook_inner(dirs.clone());
301
302    emit(
303        "whycodes",
304        "info",
305        "logging.init",
306        Some(json!({
307            "unified": unified_path.display().to_string(),
308            "debug_log": debug_log.as_ref().map(|p| p.display().to_string()),
309            "with_stderr": opts.with_stderr,
310            "debug": opts.debug,
311        })),
312    );
313
314    Ok(LoggingHandle { dirs, debug_log })
315}
316
317pub(crate) fn resolve_debug_log_path(
318    dirs: &LogDirs,
319    opts: &InitOptions,
320) -> crate::Result<Option<PathBuf>> {
321    if let Some(ref p) = opts.log_file {
322        create_parent_dir(p)?;
323        link_latest(dirs, p);
324        return Ok(Some(p.clone()));
325    }
326    if opts.debug {
327        let stamp = Utc::now().format("%Y%m%dT%H%M%S");
328        let path = dirs
329            .debug
330            .join(format!("whycodes-{}-{}.log", std::process::id(), stamp));
331        // Touch file so latest link has a target.
332        let _ = OpenOptions::new()
333            .create(true)
334            .append(true)
335            .open(&path)
336            .map_err(crate::Error::Io)?;
337        link_latest(dirs, &path);
338        return Ok(Some(path));
339    }
340    Ok(None)
341}
342
343fn link_latest(dirs: &LogDirs, target: &Path) {
344    let latest = dirs.debug.join("latest.log");
345    let _ = fs::remove_file(&latest);
346    #[cfg(unix)]
347    {
348        symlink_or_copy(target, &latest);
349    }
350    #[cfg(not(unix))]
351    {
352        let _ = fs::write(dirs.debug.join("latest.path"), target.display().to_string());
353    }
354}
355
356pub(crate) fn open_append(path: &Path) -> crate::Result<File> {
357    OpenOptions::new()
358        .create(true)
359        .append(true)
360        .open(path)
361        .map_err(crate::Error::Io)
362}
363
364pub(crate) fn create_parent_dir(p: &Path) -> crate::Result<()> {
365    if let Some(parent) = p.parent()
366        && !parent.as_os_str().is_empty()
367    {
368        fs::create_dir_all(parent).map_err(crate::Error::Io)?;
369    }
370    Ok(())
371}
372
373#[cfg(unix)]
374pub(crate) fn symlink_or_copy(target: &Path, latest: &Path) {
375    if std::os::unix::fs::symlink(target, latest).is_err() {
376        let _ = fs::copy(target, latest);
377    }
378}
379
380#[cfg(all(test, not(unix)))]
381mod windows_symlink_helper {
382    use super::*;
383
384    pub(crate) fn symlink_or_copy(target: &Path, latest: &Path) {
385        if fs::copy(target, latest).is_err() {
386            // Best-effort, same as the unix fallback.
387        }
388    }
389}
390
391#[cfg(all(test, not(unix)))]
392pub(crate) use windows_symlink_helper::symlink_or_copy;
393
394pub(crate) fn fmt_file_layer<S>(
395    file: File,
396) -> tracing_subscriber::fmt::Layer<
397    S,
398    tracing_subscriber::fmt::format::DefaultFields,
399    tracing_subscriber::fmt::format::Format,
400    Mutex<File>,
401>
402where
403    S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
404{
405    tracing_subscriber::fmt::layer()
406        .with_ansi(false)
407        .with_writer(Mutex::new(file))
408        .with_span_events(FmtSpan::NONE)
409        .with_target(true)
410}
411
412pub(crate) fn choose_debug_file(path: Option<&Path>) -> crate::Result<Option<File>> {
413    match path {
414        Some(p) => Ok(Some(open_append(p)?)),
415        None => Ok(None),
416    }
417}
418
419pub(crate) fn emit_panic_report(result: io::Result<PathBuf>, payload: &str) {
420    eprintln!("{}", crash_user_message(&result));
421    let Ok(path) = result else {
422        return;
423    };
424    emit(
425        "whycodes",
426        "error",
427        "panic",
428        Some(json!({
429            "path": path.display().to_string(),
430            "payload": payload,
431        })),
432    );
433}
434
435pub(crate) fn format_location(loc: Option<&std::panic::Location<'_>>) -> Option<String> {
436    loc.map(|loc| format!("location: {}:{}:{}\n", loc.file(), loc.line(), loc.column()))
437}
438
439pub(crate) fn crash_user_message(result: &io::Result<PathBuf>) -> String {
440    match result {
441        Ok(path) => format!("\nwhycodes crashed — report written to {}", path.display()),
442        Err(e) => format!("\nwhycodes crashed — failed to write crash report: {e}"),
443    }
444}
445
446pub(crate) fn clean_debug_value(s: String) -> String {
447    if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
448        return s[1..s.len() - 1].to_string();
449    }
450    s
451}
452
453pub(crate) fn build_env_filter(explicit: Option<&str>) -> EnvFilter {
454    // Priority: RUST_LOG → explicit (config / WHYCODES_LOG_LEVEL) → info
455    if let Ok(filter) = EnvFilter::try_from_default_env() {
456        return filter;
457    }
458    if let Some(level) = explicit
459        && let Ok(f) = EnvFilter::try_new(level)
460    {
461        return f;
462    }
463    if let Ok(level) = std::env::var("WHYCODES_LOG_LEVEL")
464        && let Ok(f) = EnvFilter::try_new(&level)
465    {
466        return f;
467    }
468    EnvFilter::new("info")
469}
470
471pub(crate) fn maybe_layer<T>(on: bool, layer: T) -> Option<T> {
472    match on {
473        true => Some(layer),
474        false => None,
475    }
476}
477
478pub(crate) fn note_try_init(ok: bool) {
479    warn_already_set((!ok).then(|| "already".into()));
480}
481
482pub(crate) fn event_message(message: Option<String>, fallback: &str) -> String {
483    match message {
484        Some(m) => m,
485        None => fallback.to_string(),
486    }
487}
488
489pub(crate) fn warn_already_set(err: Option<String>) {
490    if let Some(e) = err {
491        tracing::warn!("tracing subscriber already set: {e}");
492    }
493}
494
495pub(crate) fn stderr_fmt_layer<S>() -> impl Layer<S>
496where
497    S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
498{
499    tracing_subscriber::fmt::layer()
500        .with_writer(io::stderr)
501        .with_span_events(FmtSpan::NONE)
502        .with_target(true)
503}
504
505/// Whether logging has been initialized.
506pub fn is_initialized() -> bool {
507    INITIALIZED.load(Ordering::SeqCst)
508}
509
510/// Current log directories if init has run.
511pub fn dirs() -> Option<LogDirs> {
512    STATE.get().map(|s| s.dirs.clone())
513}
514
515// ── Panic hook ─────────────────────────────────────────────────────────────
516
517/// Register a cleanup callback (e.g. leave alternate screen) run before crash I/O.
518pub fn set_panic_cleanup(f: impl Fn() + Send + Sync + 'static) {
519    *recover_cleanup() = Some(Arc::new(f));
520}
521
522/// Clear the panic cleanup callback (e.g. after clean TUI exit).
523pub fn clear_panic_cleanup() {
524    *recover_cleanup() = None;
525}
526
527fn recover_cleanup() -> std::sync::MutexGuard<'static, Option<Arc<dyn Fn() + Send + Sync>>> {
528    recover_lock(PANIC_CLEANUP.lock())
529}
530
531pub(crate) fn recover_lock<T>(res: std::sync::LockResult<T>) -> T {
532    match res {
533        Ok(g) => g,
534        Err(p) => p.into_inner(),
535    }
536}
537
538#[cfg(test)]
539pub(crate) fn poison_panic_cleanup() {
540    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
541        let _g = PANIC_CLEANUP.lock().unwrap();
542        panic!("poison");
543    }));
544}
545
546/// Run the registered terminal cleanup if we can take `PANIC_CLEANUP`
547/// without blocking. Poison is recovered so a prior test/thread panic
548/// does not skip leaving the alternate screen. `WouldBlock` means this
549/// thread already holds the lock (e.g. panicking inside `set_panic_cleanup`).
550pub(crate) fn run_panic_cleanup() {
551    let slot = match PANIC_CLEANUP.try_lock() {
552        Ok(g) => g,
553        Err(std::sync::TryLockError::Poisoned(p)) => p.into_inner(),
554        Err(std::sync::TryLockError::WouldBlock) => return,
555    };
556    if let Some(ref cleanup) = *slot {
557        cleanup();
558    }
559}
560
561fn install_panic_hook_inner(dirs: LogDirs) {
562    let default_hook = std::panic::take_hook();
563    std::panic::set_hook(Box::new(move |info| {
564        // Restore terminal first so the user can read the panic / shell works.
565        run_panic_cleanup();
566
567        emit_panic_report(write_crash_report(&dirs, info), &panic_message(info));
568
569        default_hook(info);
570    }));
571}
572
573/// Format and write a crash report. Public for tests.
574pub fn write_crash_report(
575    dirs: &LogDirs,
576    info: &std::panic::PanicHookInfo<'_>,
577) -> io::Result<PathBuf> {
578    dirs.ensure()?;
579    let stamp = Utc::now().format("%Y%m%dT%H%M%S%.3f");
580    let path = dirs.crash_report_path(&stamp.to_string());
581    let body = format_crash_report(info);
582    fs::write(&path, body)?;
583    Ok(path)
584}
585
586/// Build crash report text (no I/O). Public for tests.
587pub fn format_crash_report(info: &std::panic::PanicHookInfo<'_>) -> String {
588    let mut out = String::new();
589    out.push_str("whycodes crash report\n");
590    out.push_str(&format!("version: {VERSION}\n"));
591    out.push_str(&format!("pid: {}\n", std::process::id()));
592    out.push_str(&format!(
593        "time: {}\n",
594        Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
595    ));
596    if let Some(line) = format_location(info.location()) {
597        out.push_str(&line);
598    }
599    out.push_str(&format!("message: {}\n", panic_message(info)));
600    out.push_str(&format!(
601        "RUST_BACKTRACE: {}\n",
602        std::env::var("RUST_BACKTRACE").unwrap_or_else(|_| "unset".into())
603    ));
604    // Capture a backtrace when the runtime provides one via std.
605    out.push_str("\nbacktrace:\n");
606    out.push_str(&format!(
607        "{:?}\n",
608        std::backtrace::Backtrace::force_capture()
609    ));
610    out
611}
612
613pub(crate) fn panic_message(info: &std::panic::PanicHookInfo<'_>) -> String {
614    info.payload_as_str()
615        .map(str::to_string)
616        .unwrap_or_else(|| "Box<dyn Any>".to_string())
617}
618
619// ── Tracing → JSONL layer ──────────────────────────────────────────────────
620
621struct JsonlLayer {
622    file: Arc<Mutex<File>>,
623    pid: u32,
624    version: String,
625}
626
627impl JsonlLayer {
628    pub(crate) fn record(&self, event: &Event<'_>) {
629        let mut visitor = JsonVisitor::default();
630        event.record(&mut visitor);
631
632        let meta = event.metadata();
633        let lvl = meta.level().as_str().to_ascii_lowercase();
634        let src = meta.target().to_string();
635        let msg = event_message(visitor.message.take(), meta.name());
636
637        let ctx_map = visitor.fields;
638        let log_event = LogEvent {
639            ts: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
640            src,
641            pid: self.pid,
642            ver: self.version.clone(),
643            lvl,
644            sid: visitor.sid,
645            msg,
646            ctx: if ctx_map.is_empty() {
647                None
648            } else {
649                Some(serde_json::Value::Object(ctx_map))
650            },
651        };
652
653        let _ = write_jsonl_locked(&self.file, &log_event);
654    }
655}
656
657impl<S> Layer<S> for JsonlLayer
658where
659    S: Subscriber,
660{
661    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
662        self.record(event);
663    }
664}
665
666#[derive(Default)]
667pub(crate) struct JsonVisitor {
668    pub(crate) message: Option<String>,
669    pub(crate) sid: Option<String>,
670    pub(crate) fields: serde_json::Map<String, serde_json::Value>,
671}
672
673impl JsonVisitor {
674    pub(crate) fn assign_str(&mut self, name: &str, value: String) {
675        if name == "message" {
676            self.message = Some(value);
677        } else if name == "sid" {
678            self.sid = Some(value);
679        } else {
680            self.fields.insert(name.to_string(), json!(value));
681        }
682    }
683}
684
685impl tracing::field::Visit for JsonVisitor {
686    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
687        self.assign_str(field.name(), clean_debug_value(format!("{value:?}")));
688    }
689
690    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
691        self.assign_str(field.name(), value.to_string());
692    }
693
694    fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
695        self.fields.insert(field.name().to_string(), json!(value));
696    }
697
698    fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
699        self.fields.insert(field.name().to_string(), json!(value));
700    }
701
702    fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
703        self.fields.insert(field.name().to_string(), json!(value));
704    }
705}
706
707// ── Tests ──────────────────────────────────────────────────────────────────
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    #[test]
714    fn log_event_dirs_and_jsonl() {
715        let ev = LogEvent::new("src", "info", "hello")
716            .with_sid("sid-1")
717            .with_ctx(serde_json::json!({"k": 1}));
718        assert_eq!(ev.src, "src");
719        assert_eq!(ev.sid.as_deref(), Some("sid-1"));
720        assert!(ev.ctx.is_some());
721
722        let dir = tempfile::tempdir().unwrap();
723        let dirs = LogDirs::from_data_dir(dir.path());
724        dirs.ensure().unwrap();
725        assert!(dirs.unified_jsonl().ends_with("unified.jsonl"));
726        assert!(
727            dirs.crash_report_path("stamp")
728                .to_string_lossy()
729                .contains("stamp")
730        );
731        append_jsonl(&dirs.unified_jsonl(), &ev).unwrap();
732        let written = std::fs::read_to_string(dirs.unified_jsonl()).unwrap();
733        assert!(written.contains("hello"));
734        assert_eq!(clean_debug_value(r#""quoted""#.into()), "quoted");
735        assert_eq!(clean_debug_value("plain".into()), "plain");
736        assert_eq!(clean_debug_value("\"open".into()), "\"open");
737        // Same i32 instantiation must take both arms (skip-expansions).
738        assert_eq!(maybe_layer(true, 7), Some(7));
739        assert_eq!(maybe_layer(false, 7), None);
740    }
741}