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#[derive(Debug, Default, Clone, PartialEq, Eq)]
58pub struct EnvFacts {
59 pub session_type: Option<String>,
61 pub wayland_display: bool,
63 pub x11_display: Option<String>,
65 pub desktop: Option<String>,
67 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
84struct StartupFacts<'a> {
87 version: &'a str,
88 os: &'a str,
89 display: &'a str,
90 webview: &'a str,
91 monitor_count: Option<usize>,
94 idle: &'a Result<u64, String>,
95 wlfix: &'a str,
96 xlib: &'a str,
101}
102
103fn 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
131pub 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
164fn 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub struct BreakClock {
228 pub since_secs: u64,
229 pub interval_secs: u64,
230}
231
232fn 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#[derive(Debug, Clone, PartialEq)]
262pub struct RuntimeSnapshot {
263 pub paused: bool,
264 pub pause_remaining_secs: Option<u64>,
266 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 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 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
335fn 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
375pub 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
389async 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
486fn 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 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 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 let wl2 = EnvFacts {
814 wayland_display: true,
815 ..Default::default()
816 };
817 assert_eq!(display_server("linux", &wl2), "Wayland");
818
819 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 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 #[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 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 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}