entracte_lib/
diagnostics.rs1use std::fs::File;
2use std::io::{Read, Seek, SeekFrom};
3use std::path::{Path, PathBuf};
4
5use sysinfo::System;
6use tauri::{AppHandle, Manager};
7
8use crate::scheduler::Scheduler;
9
10const LOG_FILE_NAME: &str = "entracte.log";
11const REPORT_LOG_BYTES: u64 = 50 * 1024;
12
13fn log_file_path(app: &AppHandle) -> PathBuf {
14 app.path()
15 .app_log_dir()
16 .unwrap_or_else(|_| std::env::temp_dir())
17 .join(LOG_FILE_NAME)
18}
19
20fn read_tail(path: &Path, max_bytes: u64) -> String {
21 let Ok(mut file) = File::open(path) else {
22 return String::new();
23 };
24 let Ok(meta) = file.metadata() else {
25 return String::new();
26 };
27 let len = meta.len();
28 let start = len.saturating_sub(max_bytes);
29 if file.seek(SeekFrom::Start(start)).is_err() {
30 return String::new();
31 }
32 let mut buf = Vec::with_capacity((len - start) as usize);
33 if file.read_to_end(&mut buf).is_err() {
34 return String::new();
35 }
36 let text = String::from_utf8_lossy(&buf).into_owned();
37 if start > 0 {
38 if let Some(idx) = text.find('\n') {
39 return text[idx + 1..].to_string();
40 }
41 }
42 text
43}
44
45fn os_description() -> String {
46 let long = System::long_os_version().unwrap_or_else(|| "unknown OS".to_string());
47 let kernel = System::kernel_version().unwrap_or_else(|| "?".to_string());
48 let arch = std::env::consts::ARCH;
49 format!("{long} (kernel {kernel}, {arch})")
50}
51
52#[tauri::command]
53pub async fn build_diagnostics_report(
54 app: AppHandle,
55 scheduler: tauri::State<'_, Scheduler>,
56) -> Result<String, String> {
57 let version = app.package_info().version.to_string();
58 let os = os_description();
59 let settings = scheduler.settings.lock().await.clone();
60 let stats = scheduler.stats.lock().await.clone();
61 let settings_value = serde_json::to_value(&settings).unwrap_or(serde_json::Value::Null);
62 let settings_value = redact_sensitive(settings_value);
63 let settings_json =
64 serde_json::to_string_pretty(&settings_value).unwrap_or_else(|_| "{}".into());
65 let stats_json = serde_json::to_string_pretty(&stats).unwrap_or_else(|_| "{}".into());
66 let log_tail = redact_log_tail(&read_tail(&log_file_path(&app), REPORT_LOG_BYTES));
67 let log_section = if log_tail.trim().is_empty() {
68 "_(log file empty or unavailable)_".to_string()
69 } else {
70 format!("```\n{}\n```", log_tail.trim_end())
71 };
72
73 Ok(format!(
74 "## Entracte diagnostics\n\n\
75 - Version: `{version}`\n\
76 - OS: `{os}`\n\n\
77 ### Settings\n\n_Hook commands are redacted from this report; share them manually if needed._\n\n```json\n{settings_json}\n```\n\n\
78 ### Stats\n\n```json\n{stats_json}\n```\n\n\
79 ### Recent log (last {kb} KB)\n\n{log_section}\n",
80 kb = REPORT_LOG_BYTES / 1024,
81 ))
82}
83
84fn redact_log_tail(tail: &str) -> String {
85 tail.lines()
86 .map(|line| {
87 if line.contains("hooks:") {
88 "<redacted: hooks log line — share separately if needed>"
89 } else {
90 line
91 }
92 })
93 .collect::<Vec<_>>()
94 .join("\n")
95}
96
97fn redact_sensitive(mut value: serde_json::Value) -> serde_json::Value {
98 if let Some(obj) = value.as_object_mut() {
99 if let Some(hooks) = obj.get_mut("hooks") {
100 if let Some(arr) = hooks.as_array_mut() {
101 let count = arr.len();
102 *hooks = serde_json::json!(format!(
103 "<redacted: {count} hook(s); commands may contain credentials>"
104 ));
105 }
106 }
107 }
108 value
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::test_support::{temp_dir, TempDir};
115 use std::fs;
116
117 fn tmp_dir() -> TempDir {
118 temp_dir()
119 }
120
121 #[test]
122 fn redact_sensitive_replaces_hooks_array_with_count_marker() {
123 let value = serde_json::json!({
124 "micro_interval_secs": 1500,
125 "hooks_enabled": true,
126 "hooks": [
127 {"event": "break_start", "command": "secret-token-xyz", "enabled": true},
128 {"event": "pause_end", "command": "another-secret", "enabled": false},
129 ],
130 });
131 let redacted = redact_sensitive(value);
132 let serialized = serde_json::to_string(&redacted).unwrap();
133 assert!(!serialized.contains("secret-token-xyz"));
134 assert!(!serialized.contains("another-secret"));
135 assert!(serialized.contains("redacted: 2 hook(s)"));
136 assert!(serialized.contains("\"micro_interval_secs\":1500"));
137 assert!(serialized.contains("\"hooks_enabled\":true"));
138 }
139
140 #[test]
141 fn redact_sensitive_handles_missing_hooks_field() {
142 let value = serde_json::json!({"micro_interval_secs": 1500});
143 let redacted = redact_sensitive(value);
144 assert_eq!(redacted, serde_json::json!({"micro_interval_secs": 1500}));
145 }
146
147 #[test]
148 fn redact_log_tail_removes_hooks_lines() {
149 let input = "[2025-01-01 INFO ipc] listening on 127.0.0.1:55432\n\
150 [2025-01-01 WARN hooks:] failed to parse command (len=42): unterminated quote\n\
151 [2025-01-01 INFO scheduler] tick\n";
152 let out = redact_log_tail(input);
153 assert!(out.contains("listening on 127.0.0.1:55432"));
154 assert!(out.contains("scheduler] tick"));
155 assert!(!out.contains("unterminated quote"));
156 assert!(out.contains("<redacted: hooks log line"));
157 }
158
159 #[test]
160 fn redact_log_tail_leaves_unrelated_lines_alone() {
161 let input = "no hook content here\nsecond line\n";
162 let out = redact_log_tail(input);
163 assert_eq!(out, "no hook content here\nsecond line");
164 }
165
166 #[test]
167 fn redact_sensitive_handles_non_object_input() {
168 let value = serde_json::json!("not-an-object");
169 let redacted = redact_sensitive(value.clone());
170 assert_eq!(redacted, value);
171 }
172
173 #[test]
174 fn read_tail_handles_missing_file() {
175 let path = PathBuf::from("/tmp/entracte-no-such-log-file.log");
176 assert_eq!(read_tail(&path, 1024), "");
177 }
178
179 #[test]
180 fn read_tail_returns_full_file_when_under_cap() {
181 let dir = tmp_dir();
182 let path = dir.path().join("entracte.log");
183 fs::write(&path, "line one\nline two\n").unwrap();
184 let tail = read_tail(&path, 1024);
185 assert_eq!(tail, "line one\nline two\n");
186 }
187
188 #[test]
189 fn read_tail_truncates_to_partial_line_then_skips_to_newline() {
190 let dir = tmp_dir();
191 let path = dir.path().join("entracte.log");
192 let body: String = (0..200).map(|i| format!("event-{i:03}\n")).collect();
193 fs::write(&path, &body).unwrap();
194 let tail = read_tail(&path, 64);
195 assert!(tail.len() <= 64);
196 assert!(tail.starts_with("event-"));
197 assert!(tail.ends_with('\n'));
198 }
199}