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
84fn 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
107pub 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
134fn 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub struct BreakClock {
193 pub since_secs: u64,
194 pub interval_secs: u64,
195}
196
197fn 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#[derive(Debug, Clone, PartialEq)]
227pub struct RuntimeSnapshot {
228 pub paused: bool,
229 pub pause_remaining_secs: Option<u64>,
231 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 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 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
325pub 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
339async 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
436fn 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 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 let wl2 = EnvFacts {
758 wayland_display: true,
759 ..Default::default()
760 };
761 assert_eq!(display_server("linux", &wl2), "Wayland");
762
763 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 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 #[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 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 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}