Skip to main content

oxi/
diagnostics.rs

1//! Runtime diagnostics for system information collection
2//!
3//! Provides utilities for gathering diagnostic information about
4//! the system, environment, and runtime state.
5
6use std::collections::HashMap;
7use std::process::Command;
8
9/// Diagnostic report entry
10#[derive(Debug, Clone, serde::Serialize)]
11pub struct DiagnosticEntry {
12    /// Category of the diagnostic
13    pub category: String,
14    /// Key within the category
15    pub key: String,
16    /// Value
17    pub value: String,
18}
19
20/// Complete diagnostic report
21#[derive(Debug, Clone, serde::Serialize)]
22pub struct DiagnosticReport {
23    /// All diagnostic entries
24    pub entries: Vec<DiagnosticEntry>,
25    /// Timestamp of the report
26    pub timestamp: chrono::DateTime<chrono::Utc>,
27    /// Version info
28    pub version: String,
29}
30
31impl DiagnosticReport {
32    /// Get all entries for a category
33    pub fn entries_for_category(&self, category: &str) -> Vec<&DiagnosticEntry> {
34        self.entries
35            .iter()
36            .filter(|e| e.category == category)
37            .collect()
38    }
39
40    /// Get a value by category and key
41    pub fn get(&self, category: &str, key: &str) -> Option<&str> {
42        self.entries
43            .iter()
44            .find(|e| e.category == category && e.key == key)
45            .map(|e| e.value.as_str())
46    }
47
48    /// Export as key-value pairs
49    pub fn to_map(&self) -> HashMap<String, String> {
50        self.entries
51            .iter()
52            .map(|e| (format!("{}.{}", e.category, e.key), e.value.clone()))
53            .collect()
54    }
55}
56
57/// Add an entry to the report
58fn add_entry(report: &mut Vec<DiagnosticEntry>, category: &str, key: &str, value: String) {
59    report.push(DiagnosticEntry {
60        category: category.to_string(),
61        key: key.to_string(),
62        value,
63    });
64}
65
66/// Collect OS information
67pub fn collect_os_info(entries: &mut Vec<DiagnosticEntry>) {
68    add_entry(entries, "os", "family", std::env::consts::OS.to_string());
69    add_entry(entries, "os", "arch", std::env::consts::ARCH.to_string());
70
71    // Get kernel version
72    if let Ok(version) = std::fs::read_to_string("/proc/version") {
73        let version = version.trim();
74        add_entry(entries, "os", "kernel", version.to_string());
75    }
76
77    // Get OS release info on Linux
78    if let Ok(lsb_release) = std::fs::read_to_string("/etc/os-release") {
79        for line in lsb_release.lines() {
80            if let Some(value) = line.strip_prefix("PRETTY_NAME=") {
81                let value = value.trim_matches('"').to_string();
82                add_entry(entries, "os", "distribution", value);
83                break;
84            }
85        }
86    }
87
88    // Get hostname
89    let hostname = std::env::var("HOSTNAME")
90        .or_else(|_| std::env::var("HOST"))
91        .unwrap_or_else(|_| "unknown".to_string());
92    add_entry(entries, "os", "hostname", hostname.trim().to_string());
93}
94
95/// Collect shell information
96pub fn collect_shell_info(entries: &mut Vec<DiagnosticEntry>) {
97    if let Ok(shell) = std::env::var("SHELL") {
98        add_entry(entries, "shell", "path", shell.clone());
99
100        // Get shell version
101        let shell_name = std::path::Path::new(&shell)
102            .file_name()
103            .and_then(|n| n.to_str())
104            .unwrap_or("unknown");
105
106        let version_output = Command::new(&shell)
107            .args(["--version"])
108            .output()
109            .ok()
110            .and_then(|o| String::from_utf8(o.stdout).ok())
111            .map(|s| s.trim().to_string())
112            .unwrap_or_else(|| "unknown".to_string());
113
114        add_entry(entries, "shell", "name", shell_name.to_string());
115        add_entry(entries, "shell", "version", version_output);
116    }
117
118    // Get terminal
119    if let Ok(term) = std::env::var("TERM") {
120        add_entry(entries, "shell", "terminal", term);
121    }
122}
123
124/// Collect tool versions
125pub fn collect_tool_versions(entries: &mut Vec<DiagnosticEntry>) {
126    let tools = vec![
127        ("git", &["git", "--version"]),
128        ("node", &["node", "--version"]),
129        ("npm", &["npm", "--version"]),
130        ("cargo", &["cargo", "--version"]),
131        ("rustc", &["rustc", "--version"]),
132        ("python", &["python3", "--version"]),
133        ("go", &["go", "version"]),
134    ];
135
136    for (name, cmd) in tools {
137        let output = Command::new(cmd[0])
138            .args(&cmd[1..])
139            .output()
140            .ok()
141            .and_then(|o| String::from_utf8(o.stdout).ok())
142            .map(|s| s.trim().to_string())
143            .unwrap_or_else(|| "not found".to_string());
144
145        add_entry(entries, "tools", name, output);
146    }
147}
148
149/// Collect environment variables
150pub fn collect_env_info(entries: &mut Vec<DiagnosticEntry>) {
151    let env_vars = vec![
152        "HOME",
153        "USER",
154        "PATH",
155        "LD_LIBRARY_PATH",
156        "DYLD_LIBRARY_PATH",
157        "PI_OFFLINE",
158        "PI_VERBOSE",
159    ];
160
161    for var in env_vars {
162        if let Ok(value) = std::env::var(var) {
163            // Truncate long values
164            let value = if value.len() > 200 {
165                format!("{}... (truncated)", &value[..200])
166            } else {
167                value
168            };
169            add_entry(entries, "env", var, value);
170        }
171    }
172}
173
174/// Collect Rust/Rs build info
175pub fn collect_build_info(entries: &mut Vec<DiagnosticEntry>) {
176    add_entry(
177        entries,
178        "build",
179        "version",
180        env!("CARGO_PKG_VERSION").to_string(),
181    );
182
183    if let Some(git_sha) = option_env!("VERGEN_GIT_SHA") {
184        add_entry(entries, "build", "git_sha", git_sha.to_string());
185    }
186
187    if let Some(build_ts) = option_env!("VERGEN_BUILD_TIMESTAMP") {
188        add_entry(entries, "build", "build_timestamp", build_ts.to_string());
189    }
190
191    add_entry(
192        entries,
193        "build",
194        "features",
195        std::env::var("CARGO_CFG_DEBUG").ok().map(|_| "debug").unwrap_or("release").to_string(),
196    );
197}
198
199/// Collect directory paths
200pub fn collect_path_info(entries: &mut Vec<DiagnosticEntry>) {
201    if let Some(home) = dirs::home_dir() {
202        add_entry(entries, "paths", "home", home.to_string_lossy().to_string());
203    }
204
205    if let Some(config) = dirs::config_dir() {
206        add_entry(
207            entries,
208            "paths",
209            "config",
210            config.join("oxi").to_string_lossy().to_string(),
211        );
212    }
213
214    if let Some(data) = dirs::data_dir() {
215        add_entry(
216            entries,
217            "paths",
218            "data",
219            data.join("oxi").to_string_lossy().to_string(),
220        );
221    }
222
223    if let Some(cache) = dirs::cache_dir() {
224        add_entry(
225            entries,
226            "paths",
227            "cache",
228            cache.join("oxi").to_string_lossy().to_string(),
229        );
230    }
231
232    if let Ok(cwd) = std::env::current_dir() {
233        add_entry(entries, "paths", "cwd", cwd.to_string_lossy().to_string());
234    }
235}
236
237/// Collect all diagnostics and generate a report
238pub fn generate_diagnostic_report() -> DiagnosticReport {
239    let mut entries = Vec::new();
240
241    collect_os_info(&mut entries);
242    collect_shell_info(&mut entries);
243    collect_tool_versions(&mut entries);
244    collect_env_info(&mut entries);
245    collect_build_info(&mut entries);
246    collect_path_info(&mut entries);
247
248    DiagnosticReport {
249        entries,
250        timestamp: chrono::Utc::now(),
251        version: env!("CARGO_PKG_VERSION").to_string(),
252    }
253}
254
255/// Format the report as a string
256pub fn format_diagnostic_report(report: &DiagnosticReport) -> String {
257    let mut output = String::new();
258
259    output.push_str(&format!("oxi Diagnostic Report - {}\n", report.timestamp));
260    output.push_str(&format!("Version: {}\n", report.version));
261    output.push_str(&"=".repeat(60));
262    output.push('\n');
263
264    let mut current_category = String::new();
265    for entry in &report.entries {
266        if entry.category != current_category {
267            current_category = entry.category.clone();
268            output.push('\n');
269            output.push_str(&format!("[{}]\n", current_category.to_uppercase()));
270        }
271        output.push_str(&format!("  {}: {}\n", entry.key, entry.value));
272    }
273
274    output
275}
276
277/// Export report as JSON
278pub fn diagnostic_report_json(report: &DiagnosticReport) -> String {
279    serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string())
280}
281
282/// Check for common issues
283pub fn check_common_issues() -> Vec<String> {
284    let mut issues = Vec::new();
285
286    // Check if running in a terminal
287    // Simple check: if stdout is not a terminal, we may be in pipe mode
288    if std::env::var("TERM").is_err() && std::env::var("COLORTERM").is_err() {
289        // Not necessarily an issue, just informational
290    }
291
292    // Check for missing required tools
293    if !crate::bash_executor::command_exists("git") {
294        issues.push("git is not installed".to_string());
295    }
296
297    // Check for common environment issues
298    if std::env::var("SHELL").is_err() {
299        issues.push("SHELL environment variable not set".to_string());
300    }
301
302    // Check home directory
303    if dirs::home_dir().is_none() {
304        issues.push("Could not determine home directory".to_string());
305    }
306
307    issues
308}
309
310/// Run diagnostics and return formatted output
311pub fn run_diagnostics() -> String {
312    let report = generate_diagnostic_report();
313    let mut output = format_diagnostic_report(&report);
314
315    let issues = check_common_issues();
316    if !issues.is_empty() {
317        output.push('\n');
318        output.push_str(&"=".repeat(60));
319        output.push('\n');
320        output.push_str("Potential Issues:\n");
321        for issue in issues {
322            output.push_str(&format!("  - {}\n", issue));
323        }
324    }
325
326    output
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_generate_diagnostic_report() {
335        let report = generate_diagnostic_report();
336        assert!(!report.entries.is_empty());
337        assert!(!report.version.is_empty());
338    }
339
340    #[test]
341    fn test_diagnostic_entry_lookup() {
342        let report = generate_diagnostic_report();
343
344        // Should have OS info
345        let os_family = report.get("os", "family");
346        assert!(os_family.is_some());
347
348        // Should have build version
349        let version = report.get("build", "version");
350        assert!(version.is_some());
351    }
352
353    #[test]
354    fn test_entries_for_category() {
355        let report = generate_diagnostic_report();
356
357        let os_entries = report.entries_for_category("os");
358        assert!(!os_entries.is_empty());
359    }
360
361    #[test]
362    fn test_to_map() {
363        let report = generate_diagnostic_report();
364        let map = report.to_map();
365        assert!(!map.is_empty());
366        assert!(map.contains_key("os.family"));
367    }
368
369    #[test]
370    fn test_format_report() {
371        let report = generate_diagnostic_report();
372        let formatted = format_diagnostic_report(&report);
373        assert!(!formatted.is_empty());
374        assert!(formatted.contains("oxi Diagnostic Report"));
375    }
376
377    #[test]
378    fn test_json_export() {
379        let report = generate_diagnostic_report();
380        let json = diagnostic_report_json(&report);
381        assert!(json.starts_with("{"));
382        assert!(json.ends_with("}"));
383    }
384
385    #[test]
386    fn test_check_common_issues() {
387        let issues = check_common_issues();
388        // Should complete without panic
389        assert!(issues.len() >= 0);
390    }
391
392    #[test]
393    fn test_run_diagnostics() {
394        let output = run_diagnostics();
395        assert!(output.contains("Diagnostic Report"));
396        assert!(output.contains("Version:"));
397    }
398
399    #[test]
400    fn test_collect_shell_info() {
401        let mut entries = Vec::new();
402        collect_shell_info(&mut entries);
403        // Shell info should be populated on systems with SHELL env var
404        assert!(!entries.is_empty() || entries.is_empty()); // Just check it doesn't panic
405    }
406
407    #[test]
408    fn test_collect_tool_versions() {
409        let mut entries = Vec::new();
410        collect_tool_versions(&mut entries);
411        // Should have at least one tool entry (likely git)
412        assert!(!entries.is_empty());
413    }
414
415    #[test]
416    fn test_diagnostic_report_timestamp() {
417        let report = generate_diagnostic_report();
418        // Timestamp should be in the past but close to now
419        let now = chrono::Utc::now();
420        let diff = now.signed_duration_since(report.timestamp);
421        assert!(diff.num_seconds() < 60);
422    }
423}