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