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 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}