Skip to main content

entracte_lib/
diagnostics.rs

1use std::fs::File;
2use std::io::{Read, Seek, SeekFrom};
3use std::path::{Path, PathBuf};
4
5use chrono::Local;
6use sysinfo::System;
7use tauri::{AppHandle, Manager, Runtime};
8
9use crate::scheduler::Scheduler;
10
11const LOG_FILE_NAME: &str = "entracte.log";
12const REPORT_LOG_BYTES: u64 = 50 * 1024;
13
14fn log_file_path<R: Runtime>(app: &AppHandle<R>) -> PathBuf {
15    app.path()
16        .app_log_dir()
17        .unwrap_or_else(|_| std::env::temp_dir())
18        .join(LOG_FILE_NAME)
19}
20
21fn read_tail(path: &Path, max_bytes: u64) -> String {
22    let Ok(mut file) = File::open(path) else {
23        return String::new();
24    };
25    let Ok(meta) = file.metadata() else {
26        return String::new();
27    };
28    let len = meta.len();
29    let start = len.saturating_sub(max_bytes);
30    if file.seek(SeekFrom::Start(start)).is_err() {
31        return String::new();
32    }
33    let mut buf = Vec::with_capacity((len - start) as usize);
34    if file.read_to_end(&mut buf).is_err() {
35        return String::new();
36    }
37    let text = String::from_utf8_lossy(&buf).into_owned();
38    if start > 0 {
39        if let Some(idx) = text.find('\n') {
40            return text[idx + 1..].to_string();
41        }
42    }
43    text
44}
45
46fn os_description() -> String {
47    let long = System::long_os_version().unwrap_or_else(|| "unknown OS".to_string());
48    let kernel = System::kernel_version().unwrap_or_else(|| "?".to_string());
49    let arch = std::env::consts::ARCH;
50    format!("{long} (kernel {kernel}, {arch})")
51}
52
53/// Host windowing-environment facts, gathered from process env vars.
54/// Only meaningful on Linux (X11 vs Wayland, compositor); on macOS /
55/// Windows the windowing system is implied by the OS. Split from the
56/// gathering so the markdown rendering is unit-testable.
57#[derive(Debug, Default, Clone, PartialEq, Eq)]
58pub struct EnvFacts {
59    /// `XDG_SESSION_TYPE` — usually `x11` or `wayland`.
60    pub session_type: Option<String>,
61    /// Whether `WAYLAND_DISPLAY` is set.
62    pub wayland_display: bool,
63    /// `DISPLAY` (X11 socket, e.g. `:0`).
64    pub x11_display: Option<String>,
65    /// `XDG_CURRENT_DESKTOP` — the desktop environment.
66    pub desktop: Option<String>,
67    /// `DESKTOP_SESSION` — the session name.
68    pub desktop_session: Option<String>,
69}
70
71impl EnvFacts {
72    fn from_env() -> Self {
73        let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
74        Self {
75            session_type: var("XDG_SESSION_TYPE"),
76            wayland_display: var("WAYLAND_DISPLAY").is_some(),
77            x11_display: var("DISPLAY"),
78            desktop: var("XDG_CURRENT_DESKTOP"),
79            desktop_session: var("DESKTOP_SESSION"),
80        }
81    }
82}
83
84/// Everything the startup banner reports, gathered by
85/// [`log_startup_banner`] so [`startup_banner`] stays pure.
86struct StartupFacts<'a> {
87    version: &'a str,
88    os: &'a str,
89    display: &'a str,
90    webview: &'a str,
91    /// `None` when the main-thread monitor read gave up — rendered as
92    /// `monitors=?`, which is a different fact from `monitors=0`.
93    monitor_count: Option<usize>,
94    idle: &'a Result<u64, String>,
95    wlfix: &'a str,
96    /// This process' Xlib locking state, from
97    /// [`crate::display::threading_state`]. The only place a failed
98    /// `XInitThreads()` becomes visible, since it runs before any logger
99    /// exists (#333).
100    xlib: &'a str,
101}
102
103/// Compact one-line environment summary logged once at startup, so the
104/// log file (and therefore every diagnostics report's log tail) always
105/// opens with the context a bug report needs — even if the user never
106/// generates a report. Pure so the formatting is unit-testable.
107fn startup_banner(facts: &StartupFacts<'_>) -> String {
108    let StartupFacts {
109        version,
110        os,
111        display,
112        webview,
113        wlfix,
114        xlib,
115        ..
116    } = facts;
117    let idle = match facts.idle {
118        Ok(secs) => format!("{secs}s"),
119        Err(e) => format!("unavailable ({e})"),
120    };
121    let monitors = match facts.monitor_count {
122        Some(n) => n.to_string(),
123        None => "?".to_string(),
124    };
125    format!(
126        "startup: Entracte {version} | os={os} | display={display} | webview={webview} \
127         | monitors={monitors} | idle={idle} | wlfix={wlfix} | xlib={xlib}"
128    )
129}
130
131/// Gather the startup facts and emit the banner at info level. Called
132/// once from the Tauri `setup` hook.
133pub fn log_startup_banner<R: Runtime>(app: &AppHandle<R>) {
134    let os = std::env::consts::OS;
135    let display = display_server(os, &EnvFacts::from_env());
136    let webview = tauri::webview_version().unwrap_or_else(|_| "unknown".to_string());
137    let monitor_count = gather_monitors(app).map(|m| m.len());
138    let idle = crate::scheduler::idle::idle_secs();
139    let wlfix = if cfg!(target_os = "linux") {
140        crate::window::wayland_fix_strategy().as_str()
141    } else {
142        "n/a"
143    };
144    let xlib = if cfg!(target_os = "linux") {
145        crate::display::threading_state().as_str()
146    } else {
147        "n/a"
148    };
149    log::info!(
150        "{}",
151        startup_banner(&StartupFacts {
152            version: &app.package_info().version.to_string(),
153            os,
154            display: &display,
155            webview: &webview,
156            monitor_count,
157            idle: &idle,
158            wlfix,
159            xlib,
160        })
161    );
162}
163
164/// Best-effort name for the display server backing the app, used to
165/// triage overlay/transparency issues that only reproduce on one stack.
166fn display_server(os: &str, env: &EnvFacts) -> String {
167    match os {
168        "macos" => "Cocoa (native)".to_string(),
169        "windows" => "Win32 / WebView2 (native)".to_string(),
170        _ => {
171            let session = env.session_type.as_deref().map(str::to_ascii_lowercase);
172            if session.as_deref() == Some("wayland") || env.wayland_display {
173                "Wayland".to_string()
174            } else if session.as_deref() == Some("x11") || env.x11_display.is_some() {
175                "X11".to_string()
176            } else {
177                "unknown".to_string()
178            }
179        }
180    }
181}
182
183/// One monitor's geometry for the report. Mirrors `tauri::Monitor`
184/// fields we care about, kept plain so `format_monitors` is testable.
185#[derive(Debug, Clone, PartialEq)]
186pub struct MonitorFacts {
187    pub name: Option<String>,
188    pub width: u32,
189    pub height: u32,
190    pub x: i32,
191    pub y: i32,
192    pub scale: f64,
193    pub primary: bool,
194}
195
196fn format_monitors(monitors: Option<&[MonitorFacts]>) -> String {
197    let Some(monitors) = monitors else {
198        return "_(monitor list unavailable \u{2014} the windowing main thread did not answer; \
199                see the log for a `display:` warning)_"
200            .to_string();
201    };
202    if monitors.is_empty() {
203        return "_(no monitors reported \u{2014} headless or windowing system unavailable)_"
204            .to_string();
205    }
206    monitors
207        .iter()
208        .map(|m| {
209            let name = m.name.as_deref().unwrap_or("unnamed");
210            let primary = if m.primary { " (primary)" } else { "" };
211            format!(
212                "- `{name}`{primary}: {w}\u{00d7}{h} @ ({x}, {y}), scale {scale:.2}",
213                w = m.width,
214                h = m.height,
215                x = m.x,
216                y = m.y,
217                scale = m.scale,
218            )
219        })
220        .collect::<Vec<_>>()
221        .join("\n")
222}
223
224/// Per-kind interval clock: how long since this kind last fired and the
225/// configured interval, so the report can show "due in / overdue by".
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub struct BreakClock {
228    pub since_secs: u64,
229    pub interval_secs: u64,
230}
231
232/// Seconds until the next break of a kind: positive = still counting
233/// down, zero or negative = overdue (the scheduler will fire as soon as
234/// no guard is suppressing it). `i64` so overdue is representable.
235fn next_due_secs(clock: BreakClock) -> i64 {
236    clock.interval_secs as i64 - clock.since_secs as i64
237}
238
239fn format_break_clock(label: &str, clock: Option<BreakClock>) -> String {
240    match clock {
241        None => format!("- {label}: disabled"),
242        Some(c) => {
243            let due = next_due_secs(c);
244            let when = if due > 0 {
245                format!("due in {due}s")
246            } else {
247                format!("overdue by {}s", -due)
248            };
249            format!(
250                "- {label}: last fired {since}s ago, interval {interval}s ({when})",
251                since = c.since_secs,
252                interval = c.interval_secs,
253            )
254        }
255    }
256}
257
258/// Everything the report needs to answer "why isn't a break happening
259/// right now". Gathered live from the scheduler + windowing system, then
260/// rendered by `format_runtime_snapshot` (kept pure for testing).
261#[derive(Debug, Clone, PartialEq)]
262pub struct RuntimeSnapshot {
263    pub paused: bool,
264    /// `Some` only for a timed pause; `None` while paused means indefinite.
265    pub pause_remaining_secs: Option<u64>,
266    /// Human label of the active auto-suppression, or `None` if breaks
267    /// are not being auto-suppressed this instant.
268    pub auto_suppress: Option<&'static str>,
269    pub dnd_active: bool,
270    pub camera_active: bool,
271    pub video_active: bool,
272    pub screen_locked: Option<bool>,
273    /// Live `UserIdle` probe: idle seconds, or the error string if the
274    /// platform query failed (the #67 "MIT-SCREEN-SAVER missing" case).
275    pub idle_probe: Result<u64, String>,
276    pub active_break: Option<&'static str>,
277    pub micro: Option<BreakClock>,
278    pub long: Option<BreakClock>,
279    pub micro_postpones: u32,
280    pub long_postpones: u32,
281    pub notification_permission: String,
282    pub autostart_setting: bool,
283    /// OS-level autostart registration, or `None` if it couldn't be read.
284    pub autostart_registered: Option<bool>,
285}
286
287fn format_runtime_snapshot(s: &RuntimeSnapshot) -> String {
288    let pause = if !s.paused {
289        "running".to_string()
290    } else {
291        match s.pause_remaining_secs {
292            Some(secs) => format!("paused ({secs}s remaining)"),
293            None => "paused (indefinitely)".to_string(),
294        }
295    };
296    let suppress = s.auto_suppress.unwrap_or("nothing");
297    let lock = match s.screen_locked {
298        Some(true) => "locked",
299        Some(false) => "unlocked",
300        None => "unknown",
301    };
302    let idle = match &s.idle_probe {
303        Ok(secs) => format!("{secs}s idle"),
304        Err(e) => format!("unavailable ({e})"),
305    };
306    let autostart_os = match s.autostart_registered {
307        Some(true) => "registered",
308        Some(false) => "not registered",
309        None => "unknown",
310    };
311    let active = s.active_break.unwrap_or("none");
312    format!(
313        "- Pause: {pause}\n\
314         - Auto-suppressed by: {suppress}\n\
315         - Sensors: camera `{cam}`, video `{vid}`, DND `{dnd}`, screen `{lock}`\n\
316         - Idle detection: {idle}\n\
317         - Active break: {active}\n\
318         {micro}\n\
319         {long}\n\
320         - Postpones this session: micro {micro_p}, long {long_p}\n\
321         - Notification permission: `{notif}`\n\
322         - Autostart: setting `{set}`, OS `{autostart_os}`",
323        cam = s.camera_active,
324        vid = s.video_active,
325        dnd = s.dnd_active,
326        micro = format_break_clock("Micro", s.micro),
327        long = format_break_clock("Long", s.long),
328        micro_p = s.micro_postpones,
329        long_p = s.long_postpones,
330        notif = s.notification_permission,
331        set = s.autostart_setting,
332    )
333}
334
335/// Describe every attached monitor, or `None` if the windowing main thread
336/// could not be asked — see [`crate::display::on_main_thread`] for why the
337/// read has to happen there and why calling it from either of this function's
338/// callers (an `async` command task and the main-thread `setup` hook) is safe.
339///
340/// `None` is kept distinct from `Some(vec![])` all the way to the rendered
341/// report: "we could not ask" and "there are no monitors" are different
342/// facts, and conflating them sends the reader after the wrong bug.
343fn gather_monitors<R: Runtime>(app: &AppHandle<R>) -> Option<Vec<MonitorFacts>> {
344    crate::display::on_main_thread(app, "diagnostics monitors", read_monitor_facts)
345}
346
347fn read_monitor_facts<R: Runtime>(app: &AppHandle<R>) -> Vec<MonitorFacts> {
348    let primary = app.primary_monitor().ok().flatten();
349    let primary_key = primary
350        .as_ref()
351        .map(|m| (m.name().cloned(), m.position().x, m.position().y));
352    app.available_monitors()
353        .unwrap_or_default()
354        .iter()
355        .map(|m| {
356            let pos = m.position();
357            let size = m.size();
358            let name = m.name().cloned();
359            let primary = primary_key.as_ref().is_some_and(|(pname, px, py)| {
360                pname.as_deref() == name.as_deref() && *px == pos.x && *py == pos.y
361            });
362            MonitorFacts {
363                name,
364                width: size.width,
365                height: size.height,
366                x: pos.x,
367                y: pos.y,
368                scale: m.scale_factor(),
369                primary,
370            }
371        })
372        .collect()
373}
374
375/// Volatile readings gathered by the command from the OS / plugins:
376/// the idle probe, DND state, screen-lock, notification permission, and
377/// OS-level autostart. Bundled and passed into `runtime_snapshot_section`
378/// so that function touches only in-memory scheduler state — these probes
379/// go through X11 / dbus / plugin FFI that segfaults on the headless CI
380/// runner, so they must stay out of the testable path.
381pub struct LiveReadings {
382    pub idle_probe: Result<u64, String>,
383    pub dnd_active: bool,
384    pub screen_locked: Option<bool>,
385    pub notification_permission: String,
386    pub autostart_registered: Option<bool>,
387}
388
389/// Build the Runtime section from in-memory scheduler state plus the
390/// already-gathered `LiveReadings`. No OS / plugin / `AppHandle` access,
391/// so it's testable with a bare scheduler.
392async fn runtime_snapshot_section(scheduler: &Scheduler, live: LiveReadings) -> String {
393    use std::sync::atomic::Ordering;
394    use std::time::Instant;
395
396    let s = scheduler.settings.lock().await.clone();
397
398    let (paused, pause_remaining_secs) = {
399        let state = scheduler.pause_state.lock().await;
400        match &*state {
401            crate::scheduler::PauseState::Running => (false, None),
402            crate::scheduler::PauseState::PausedUntil(None) => (true, None),
403            crate::scheduler::PauseState::PausedUntil(Some(deadline)) => (
404                true,
405                Some(deadline.saturating_duration_since(Instant::now()).as_secs()),
406            ),
407        }
408    };
409
410    let auto_suppress = crate::scheduler::SuppressReason::from_u8(
411        scheduler.auto_suppress_reason.load(Ordering::Relaxed),
412    )
413    .map(|r| r.human());
414
415    let (active_break, micro, long, micro_postpones, long_postpones) = {
416        let t = scheduler.timers.lock().await;
417        let now = Instant::now();
418        let micro = s.micro_enabled.then_some(BreakClock {
419            since_secs: now.saturating_duration_since(t.last_micro).as_secs(),
420            interval_secs: s.micro_interval_secs,
421        });
422        let long = s.long_enabled.then_some(BreakClock {
423            since_secs: now.saturating_duration_since(t.last_long).as_secs(),
424            interval_secs: s.long_interval_secs,
425        });
426        (
427            t.active_break.map(break_kind_label),
428            micro,
429            long,
430            t.micro_postpone_count,
431            t.long_postpone_count,
432        )
433    };
434
435    let snapshot = RuntimeSnapshot {
436        paused,
437        pause_remaining_secs,
438        auto_suppress,
439        dnd_active: live.dnd_active,
440        camera_active: scheduler.camera_active.load(Ordering::Relaxed),
441        video_active: scheduler.video_active.load(Ordering::Relaxed),
442        screen_locked: live.screen_locked,
443        idle_probe: live.idle_probe,
444        active_break,
445        micro,
446        long,
447        micro_postpones,
448        long_postpones,
449        notification_permission: live.notification_permission,
450        autostart_setting: s.autostart_enabled,
451        autostart_registered: live.autostart_registered,
452    };
453    format_runtime_snapshot(&snapshot)
454}
455
456fn break_kind_label(kind: crate::scheduler::BreakKind) -> &'static str {
457    match kind {
458        crate::scheduler::BreakKind::Micro => "micro",
459        crate::scheduler::BreakKind::Long => "long",
460        crate::scheduler::BreakKind::Sleep => "bedtime",
461    }
462}
463
464fn environment_section<R: Runtime>(app: &AppHandle<R>) -> String {
465    let os = std::env::consts::OS;
466    let env = EnvFacts::from_env();
467    let webview = tauri::webview_version().unwrap_or_else(|_| "unknown".to_string());
468    let monitors = gather_monitors(app);
469    let now = Local::now();
470    let build_profile = if cfg!(debug_assertions) {
471        "debug"
472    } else {
473        "release"
474    };
475    format_environment(
476        os,
477        &env,
478        &webview,
479        monitors.as_deref(),
480        &now.format("%Y-%m-%d %H:%M:%S").to_string(),
481        &now.format("%:z").to_string(),
482        build_profile,
483    )
484}
485
486/// Render the whole environment block from already-gathered facts.
487/// Pure: the caller does the env / windowing-system queries and passes
488/// the results in, so this formatting is fully unit-testable.
489fn format_environment(
490    os: &str,
491    env: &EnvFacts,
492    webview: &str,
493    monitors: Option<&[MonitorFacts]>,
494    local_now: &str,
495    utc_offset: &str,
496    build_profile: &str,
497) -> String {
498    let mut lines = vec![format!("- Display server: `{}`", display_server(os, env))];
499    if os != "macos" && os != "windows" {
500        let desktop = env.desktop.as_deref().unwrap_or("?");
501        let session = env.desktop_session.as_deref().unwrap_or("?");
502        lines.push(format!("- Desktop: `{desktop}` (session `{session}`)"));
503    }
504    lines.push(format!("- Webview: `{webview}`"));
505    lines.push(format!("- Build: `{build_profile}`"));
506    lines.push(format!(
507        "- Local time: `{local_now}` (UTC offset `{utc_offset}`)"
508    ));
509    format!(
510        "{lines}\n\n**Monitors**\n\n{monitors}",
511        lines = lines.join("\n"),
512        monitors = format_monitors(monitors),
513    )
514}
515
516#[tauri::command]
517pub async fn build_diagnostics_report<R: Runtime>(
518    app: AppHandle<R>,
519    scheduler: tauri::State<'_, Scheduler>,
520) -> Result<String, String> {
521    let version = app.package_info().version.to_string();
522    let os = os_description();
523    let environment = environment_section(&app);
524    let notification_permission = {
525        use tauri_plugin_notification::NotificationExt;
526        app.notification()
527            .permission_state()
528            .map(|p| p.to_string())
529            .unwrap_or_else(|e| format!("unknown ({e})"))
530    };
531    let autostart_registered = {
532        use tauri_plugin_autostart::ManagerExt;
533        app.autolaunch().is_enabled().ok()
534    };
535    let live = LiveReadings {
536        idle_probe: crate::scheduler::idle::idle_secs(),
537        dnd_active: crate::dnd::is_active(),
538        screen_locked: crate::scheduler::session_lock::screen_locked(),
539        notification_permission,
540        autostart_registered,
541    };
542    let runtime = runtime_snapshot_section(scheduler.inner(), live).await;
543    let settings = scheduler.settings.lock().await.clone();
544    let stats = scheduler.stats.lock().await.clone();
545    let settings_value = serde_json::to_value(&settings).unwrap_or(serde_json::Value::Null);
546    let settings_value = redact_sensitive(settings_value);
547    let settings_json =
548        serde_json::to_string_pretty(&settings_value).unwrap_or_else(|_| "{}".into());
549    let stats_json = serde_json::to_string_pretty(&stats).unwrap_or_else(|_| "{}".into());
550    let log_tail = redact_log_tail(&read_tail(&log_file_path(&app), REPORT_LOG_BYTES));
551    let log_section = if log_tail.trim().is_empty() {
552        "_(log file empty or unavailable)_".to_string()
553    } else {
554        format!("```\n{}\n```", log_tail.trim_end())
555    };
556
557    Ok(format!(
558        "## Entracte diagnostics\n\n\
559        - Version: `{version}`\n\
560        - OS: `{os}`\n\n\
561        ### Environment\n\n{environment}\n\n\
562        ### Runtime\n\n{runtime}\n\n\
563        ### Settings\n\n_Hook commands are redacted from this report; share them manually if needed._\n\n```json\n{settings_json}\n```\n\n\
564        ### Stats\n\n```json\n{stats_json}\n```\n\n\
565        ### Recent log (last {kb} KB)\n\n{log_section}\n",
566        kb = REPORT_LOG_BYTES / 1024,
567    ))
568}
569
570fn redact_log_tail(tail: &str) -> String {
571    tail.lines()
572        .map(|line| {
573            if line.contains("hooks:") {
574                "<redacted: hooks log line — share separately if needed>".to_string()
575            } else {
576                crate::license_redact::redact_license_shapes(line)
577            }
578        })
579        .collect::<Vec<_>>()
580        .join("\n")
581}
582
583fn redact_sensitive(mut value: serde_json::Value) -> serde_json::Value {
584    if let Some(obj) = value.as_object_mut() {
585        if let Some(hooks) = obj.get_mut("hooks") {
586            if let Some(arr) = hooks.as_array_mut() {
587                let count = arr.len();
588                *hooks = serde_json::json!(format!(
589                    "<redacted: {count} hook(s); commands may contain credentials>"
590                ));
591            }
592        }
593        for (key, field) in obj.iter_mut() {
594            if key.ends_with("_hints") {
595                collapse_hint_pool(field);
596            }
597        }
598    }
599    value
600}
601
602fn collapse_hint_pool(field: &mut serde_json::Value) {
603    if let Some(arr) = field.as_array() {
604        let count = arr.len();
605        *field = serde_json::json!(format!("<{count} hint(s); omitted from report>"));
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612    use crate::test_support::{temp_dir, TempDir};
613    use std::fs;
614
615    fn tmp_dir() -> TempDir {
616        temp_dir()
617    }
618
619    #[test]
620    fn redact_sensitive_replaces_hooks_array_with_count_marker() {
621        let value = serde_json::json!({
622            "micro_interval_secs": 1500,
623            "hooks_enabled": true,
624            "hooks": [
625                {"event": "break_start", "command": "secret-token-xyz", "enabled": true},
626                {"event": "pause_end", "command": "another-secret", "enabled": false},
627            ],
628        });
629        let redacted = redact_sensitive(value);
630        let serialized = serde_json::to_string(&redacted).unwrap();
631        assert!(!serialized.contains("secret-token-xyz"));
632        assert!(!serialized.contains("another-secret"));
633        assert!(serialized.contains("redacted: 2 hook(s)"));
634        assert!(serialized.contains("\"micro_interval_secs\":1500"));
635        assert!(serialized.contains("\"hooks_enabled\":true"));
636    }
637
638    #[test]
639    fn redact_sensitive_handles_missing_hooks_field() {
640        let value = serde_json::json!({"micro_interval_secs": 1500});
641        let redacted = redact_sensitive(value);
642        assert_eq!(redacted, serde_json::json!({"micro_interval_secs": 1500}));
643    }
644
645    #[test]
646    fn redact_log_tail_removes_hooks_lines() {
647        let input = "[2025-01-01 INFO ipc] listening on 127.0.0.1:55432\n\
648                     [2025-01-01 WARN hooks:] failed to parse command (len=42): unterminated quote\n\
649                     [2025-01-01 INFO scheduler] tick\n";
650        let out = redact_log_tail(input);
651        assert!(out.contains("listening on 127.0.0.1:55432"));
652        assert!(out.contains("scheduler] tick"));
653        assert!(!out.contains("unterminated quote"));
654        assert!(out.contains("<redacted: hooks log line"));
655    }
656
657    #[test]
658    fn redact_log_tail_leaves_unrelated_lines_alone() {
659        let input = "no hook content here\nsecond line\n";
660        let out = redact_log_tail(input);
661        assert_eq!(out, "no hook content here\nsecond line");
662    }
663
664    #[test]
665    fn redact_log_tail_masks_lemon_squeezy_keys_outside_hook_lines() {
666        let input =
667            "[2025-01-01 INFO supporter] activated key=ABCD-1111-2222-3333 user@example.com\n";
668        let out = redact_log_tail(input);
669        assert!(out.contains("[REDACTED-LS-KEY]"), "got: {out}");
670        assert!(!out.contains("ABCD-1111-2222-3333"));
671    }
672
673    #[test]
674    fn redact_log_tail_masks_manual_tokens() {
675        let input = "[INFO supporter] verifying token ENT1-AAAAAAAAAAAA done\n";
676        let out = redact_log_tail(input);
677        assert!(out.contains("[REDACTED-MANUAL-TOKEN]"), "got: {out}");
678    }
679
680    #[test]
681    fn redact_log_tail_keeps_short_ent1_prefix_intact() {
682        // ENT1-XY is shorter than a real token — must not be redacted.
683        let input = "[INFO file] opening ENT1-XY.json\n";
684        let out = redact_log_tail(input);
685        assert_eq!(out, "[INFO file] opening ENT1-XY.json");
686    }
687
688    #[test]
689    fn redact_log_tail_rejects_ls_key_with_non_alnum_group() {
690        let input = "[INFO bug] reproducer ABCD-1111-22!2-3333-4444 oh no\n";
691        let out = redact_log_tail(input);
692        assert!(!out.contains("[REDACTED-LS-KEY]"), "got: {out}");
693    }
694
695    #[test]
696    fn redact_sensitive_collapses_hint_pools_into_count_markers() {
697        let value = serde_json::json!({
698            "micro_interval_secs": 1500,
699            "micro_physical_hints": ["Stretch", "Blink", "Stand up"],
700            "micro_psychological_hints": ["Breathe"],
701            "long_hints": ["Walk", "Hydrate"],
702            "long_social_hints": [],
703            "sleep_hints": ["Dim the lights", "Put the phone down"],
704        });
705        let redacted = redact_sensitive(value);
706        let serialized = serde_json::to_string(&redacted).unwrap();
707        assert!(!serialized.contains("Stretch"));
708        assert!(!serialized.contains("Hydrate"));
709        assert!(!serialized.contains("Dim the lights"));
710        assert!(
711            serialized.contains("\"micro_physical_hints\":\"<3 hint(s); omitted from report>\"")
712        );
713        assert!(serialized
714            .contains("\"micro_psychological_hints\":\"<1 hint(s); omitted from report>\""));
715        assert!(serialized.contains("\"long_hints\":\"<2 hint(s); omitted from report>\""));
716        assert!(serialized.contains("\"long_social_hints\":\"<0 hint(s); omitted from report>\""));
717        assert!(serialized.contains("\"sleep_hints\":\"<2 hint(s); omitted from report>\""));
718        assert!(serialized.contains("\"micro_interval_secs\":1500"));
719    }
720
721    #[test]
722    fn redact_sensitive_handles_missing_hint_fields() {
723        let value = serde_json::json!({"micro_interval_secs": 1500, "long_enabled": true});
724        let redacted = redact_sensitive(value);
725        assert_eq!(
726            redacted,
727            serde_json::json!({"micro_interval_secs": 1500, "long_enabled": true})
728        );
729    }
730
731    #[test]
732    fn redact_sensitive_leaves_non_hint_fields_untouched() {
733        let value = serde_json::json!({
734            "micro_physical_hints": ["Stretch"],
735            "active_hours": [9, 17],
736            "overlay_theme": "calm",
737        });
738        let redacted = redact_sensitive(value);
739        assert_eq!(redacted["active_hours"], serde_json::json!([9, 17]));
740        assert_eq!(redacted["overlay_theme"], serde_json::json!("calm"));
741        assert_eq!(
742            redacted["micro_physical_hints"],
743            serde_json::json!("<1 hint(s); omitted from report>")
744        );
745    }
746
747    #[test]
748    fn redact_sensitive_handles_non_object_input() {
749        let value = serde_json::json!("not-an-object");
750        let redacted = redact_sensitive(value.clone());
751        assert_eq!(redacted, value);
752    }
753
754    #[test]
755    fn startup_banner_is_a_compact_one_liner_with_idle_state() {
756        let ok = startup_banner(&StartupFacts {
757            version: "0.0.1",
758            os: "macos",
759            display: "Cocoa (native)",
760            webview: "WKWebView",
761            monitor_count: Some(2),
762            idle: &Ok(3),
763            wlfix: "n/a",
764            xlib: "n/a",
765        });
766        assert!(ok.starts_with("startup: Entracte 0.0.1"));
767        assert!(ok.contains("os=macos"));
768        assert!(ok.contains("display=Cocoa (native)"));
769        assert!(ok.contains("monitors=2"));
770        assert!(ok.contains("idle=3s"));
771        assert!(ok.contains("wlfix=n/a"));
772        assert!(ok.contains("xlib=n/a"));
773        assert!(!ok.contains('\n'), "banner must be a single line");
774
775        let bad = startup_banner(&StartupFacts {
776            version: "0.0.1",
777            os: "linux",
778            display: "Wayland",
779            webview: "WebKitGTK",
780            monitor_count: None,
781            idle: &Err("Status not OK".into()),
782            wlfix: "maximize",
783            xlib: "off",
784        });
785        assert!(bad.contains("idle=unavailable (Status not OK)"));
786        assert!(bad.contains("wlfix=maximize"));
787        assert!(bad.contains("xlib=off"));
788        // Not `monitors=0`: a read that never answered is not "no monitors".
789        assert!(bad.contains("monitors=?"));
790    }
791
792    #[test]
793    fn display_server_is_native_on_desktop_oses() {
794        assert_eq!(
795            display_server("macos", &EnvFacts::default()),
796            "Cocoa (native)"
797        );
798        assert_eq!(
799            display_server("windows", &EnvFacts::default()),
800            "Win32 / WebView2 (native)"
801        );
802    }
803
804    #[test]
805    fn display_server_infers_wayland_then_x11_then_unknown() {
806        let wl = EnvFacts {
807            session_type: Some("wayland".into()),
808            ..Default::default()
809        };
810        assert_eq!(display_server("linux", &wl), "Wayland");
811
812        // Wayland socket present even though session_type is unset.
813        let wl2 = EnvFacts {
814            wayland_display: true,
815            ..Default::default()
816        };
817        assert_eq!(display_server("linux", &wl2), "Wayland");
818
819        // The #67 shape: only DISPLAY set → X11.
820        let x = EnvFacts {
821            x11_display: Some(":0".into()),
822            ..Default::default()
823        };
824        assert_eq!(display_server("linux", &x), "X11");
825
826        assert_eq!(display_server("linux", &EnvFacts::default()), "unknown");
827    }
828
829    #[test]
830    fn format_monitors_lists_each_with_primary_marker() {
831        let out = format_monitors(Some(&[
832            MonitorFacts {
833                name: Some("eDP-1".into()),
834                width: 2560,
835                height: 1440,
836                x: 0,
837                y: 0,
838                scale: 2.0,
839                primary: true,
840            },
841            MonitorFacts {
842                name: None,
843                width: 1920,
844                height: 1080,
845                x: 2560,
846                y: 0,
847                scale: 1.0,
848                primary: false,
849            },
850        ]));
851        assert!(out.contains("`eDP-1` (primary): 2560\u{00d7}1440 @ (0, 0), scale 2.00"));
852        assert!(out.contains("`unnamed`: 1920\u{00d7}1080 @ (2560, 0), scale 1.00"));
853        assert!(!out.contains("unnamed (primary)"));
854    }
855
856    #[test]
857    fn format_monitors_notes_when_none_reported() {
858        let out = format_monitors(Some(&[]));
859        assert!(out.contains("no monitors reported"));
860    }
861
862    #[test]
863    fn format_monitors_distinguishes_an_unanswered_read_from_no_monitors() {
864        let unavailable = format_monitors(None);
865        assert!(unavailable.contains("monitor list unavailable"));
866        assert!(!unavailable.contains("no monitors reported"));
867    }
868
869    #[test]
870    fn format_environment_includes_desktop_only_on_linux() {
871        let env = EnvFacts {
872            session_type: Some("x11".into()),
873            x11_display: Some(":0".into()),
874            desktop: Some("GNOME".into()),
875            desktop_session: Some("ubuntu".into()),
876            ..Default::default()
877        };
878        let linux = format_environment(
879            "linux",
880            &env,
881            "WebKitGTK 2.44",
882            Some(&[]),
883            "2026-05-29 10:00:00",
884            "+02:00",
885            "release",
886        );
887        assert!(linux.contains("Display server: `X11`"));
888        assert!(linux.contains("Desktop: `GNOME` (session `ubuntu`)"));
889        assert!(linux.contains("Webview: `WebKitGTK 2.44`"));
890        assert!(linux.contains("UTC offset `+02:00`"));
891
892        let mac = format_environment(
893            "macos",
894            &EnvFacts::default(),
895            "WKWebView",
896            Some(&[]),
897            "2026-05-29 10:00:00",
898            "-07:00",
899            "debug",
900        );
901        assert!(mac.contains("Cocoa (native)"));
902        assert!(!mac.contains("Desktop: "));
903        assert!(mac.contains("Build: `debug`"));
904    }
905
906    #[test]
907    fn next_due_secs_positive_when_counting_down_negative_when_overdue() {
908        assert_eq!(
909            next_due_secs(BreakClock {
910                since_secs: 100,
911                interval_secs: 1200
912            }),
913            1100
914        );
915        assert_eq!(
916            next_due_secs(BreakClock {
917                since_secs: 1300,
918                interval_secs: 1200
919            }),
920            -100
921        );
922    }
923
924    #[test]
925    fn format_break_clock_reports_disabled_due_and_overdue() {
926        assert_eq!(format_break_clock("Micro", None), "- Micro: disabled");
927        let due = format_break_clock(
928            "Micro",
929            Some(BreakClock {
930                since_secs: 100,
931                interval_secs: 1200,
932            }),
933        );
934        assert!(due.contains("last fired 100s ago"));
935        assert!(due.contains("due in 1100s"));
936        let overdue = format_break_clock(
937            "Long",
938            Some(BreakClock {
939                since_secs: 1300,
940                interval_secs: 1200,
941            }),
942        );
943        assert!(overdue.contains("overdue by 100s"));
944    }
945
946    fn sample_snapshot() -> RuntimeSnapshot {
947        RuntimeSnapshot {
948            paused: false,
949            pause_remaining_secs: None,
950            auto_suppress: None,
951            dnd_active: false,
952            camera_active: false,
953            video_active: false,
954            screen_locked: Some(false),
955            idle_probe: Ok(5),
956            active_break: None,
957            micro: Some(BreakClock {
958                since_secs: 10,
959                interval_secs: 1200,
960            }),
961            long: None,
962            micro_postpones: 0,
963            long_postpones: 0,
964            notification_permission: "granted".into(),
965            autostart_setting: true,
966            autostart_registered: Some(true),
967        }
968    }
969
970    #[test]
971    fn format_runtime_snapshot_reports_a_healthy_running_state() {
972        let out = format_runtime_snapshot(&sample_snapshot());
973        assert!(out.contains("Pause: running"));
974        assert!(out.contains("Auto-suppressed by: nothing"));
975        assert!(out.contains("screen `unlocked`"));
976        assert!(out.contains("Idle detection: 5s idle"));
977        assert!(out.contains("Active break: none"));
978        assert!(out.contains("- Long: disabled"));
979        assert!(out.contains("Notification permission: `granted`"));
980        assert!(out.contains("Autostart: setting `true`, OS `registered`"));
981    }
982
983    #[test]
984    fn format_runtime_snapshot_surfaces_the_blocking_conditions() {
985        let snap = RuntimeSnapshot {
986            paused: true,
987            pause_remaining_secs: Some(600),
988            auto_suppress: Some(crate::scheduler::SuppressReason::Dnd.human()),
989            dnd_active: true,
990            // The #67 shape: idle query failed, breaks still expected to fire.
991            idle_probe: Err("MIT-SCREEN-SAVER missing".into()),
992            active_break: Some("micro"),
993            ..sample_snapshot()
994        };
995        let out = format_runtime_snapshot(&snap);
996        assert!(out.contains("Pause: paused (600s remaining)"));
997        assert!(out.contains("Auto-suppressed by: Do Not Disturb"));
998        assert!(out.contains("DND `true`"));
999        assert!(out.contains("Idle detection: unavailable (MIT-SCREEN-SAVER missing)"));
1000        assert!(out.contains("Active break: micro"));
1001    }
1002
1003    #[test]
1004    fn format_runtime_snapshot_marks_indefinite_pause_and_unknown_states() {
1005        let snap = RuntimeSnapshot {
1006            paused: true,
1007            pause_remaining_secs: None,
1008            screen_locked: None,
1009            autostart_registered: None,
1010            ..sample_snapshot()
1011        };
1012        let out = format_runtime_snapshot(&snap);
1013        assert!(out.contains("Pause: paused (indefinitely)"));
1014        assert!(out.contains("screen `unknown`"));
1015        assert!(out.contains("OS `unknown`"));
1016    }
1017
1018    // Integration test for the Runtime-section gathering: pause /
1019    // suppression / sensors / idle probe / timers, assembled from a live
1020    // scheduler. The two plugin-derived values are passed in (as the
1021    // command does), so no mock app / AppHandle is needed — that matters
1022    // because the notification/autostart plugins under MockRuntime abort on
1023    // the headless Linux runner, the only platform CI runs tests on. The
1024    // Environment/monitor glue stays uncovered for the same runtime reason;
1025    // only its pure formatters (above) are exercised.
1026    #[tokio::test]
1027    async fn runtime_snapshot_section_reports_live_scheduler_state() {
1028        use crate::scheduler::{Scheduler, Settings};
1029
1030        let dir = temp_dir();
1031        let sched = Scheduler::for_test(
1032            vec![crate::config::Profile {
1033                name: crate::config::DEFAULT_PROFILE_NAME.to_string(),
1034                settings: Settings::default(),
1035            }],
1036            crate::config::DEFAULT_PROFILE_NAME,
1037            dir.path(),
1038        );
1039
1040        let live = LiveReadings {
1041            idle_probe: Ok(7),
1042            dnd_active: false,
1043            screen_locked: Some(false),
1044            notification_permission: "granted".to_string(),
1045            autostart_registered: Some(true),
1046        };
1047        let section = runtime_snapshot_section(&sched, live).await;
1048
1049        // A fresh scheduler is running, not suppressed, no active break.
1050        assert!(section.contains("Pause: running"));
1051        assert!(section.contains("Auto-suppressed by: nothing"));
1052        assert!(section.contains("Active break: none"));
1053        assert!(section.contains("Idle detection:"));
1054        assert!(section.contains("Notification permission: `granted`"));
1055        assert!(section.contains("Autostart: setting `false`, OS `registered`"));
1056        // Micro is enabled by default, so its break clock should render.
1057        assert!(section.contains("- Micro:"));
1058    }
1059
1060    #[test]
1061    fn read_tail_handles_missing_file() {
1062        let path = PathBuf::from("/tmp/entracte-no-such-log-file.log");
1063        assert_eq!(read_tail(&path, 1024), "");
1064    }
1065
1066    #[test]
1067    fn read_tail_returns_full_file_when_under_cap() {
1068        let dir = tmp_dir();
1069        let path = dir.path().join("entracte.log");
1070        fs::write(&path, "line one\nline two\n").unwrap();
1071        let tail = read_tail(&path, 1024);
1072        assert_eq!(tail, "line one\nline two\n");
1073    }
1074
1075    #[test]
1076    fn read_tail_truncates_to_partial_line_then_skips_to_newline() {
1077        let dir = tmp_dir();
1078        let path = dir.path().join("entracte.log");
1079        let body: String = (0..200).map(|i| format!("event-{i:03}\n")).collect();
1080        fs::write(&path, &body).unwrap();
1081        let tail = read_tail(&path, 64);
1082        assert!(tail.len() <= 64);
1083        assert!(tail.starts_with("event-"));
1084        assert!(tail.ends_with('\n'));
1085    }
1086}