sesame/doctor/
mod.rs

1//! `sesame status --doctor` — system health and security posture diagnostics.
2//!
3//! Each check category is a separate module returning `Vec<Check>`.
4//! The runner collects, filters, formats, and optionally sets the exit code.
5
6mod daemon;
7mod memory;
8mod platform;
9mod sandbox;
10
11use owo_colors::OwoColorize;
12
13/// Result of a single diagnostic check.
14#[derive(Debug, Clone)]
15pub struct Check {
16    /// Unique identifier (e.g. "daemon.wm.running").
17    pub id: String,
18    /// Category for grouping and filtering.
19    pub category: &'static str,
20    /// Pass / Warn / Fail.
21    pub status: Status,
22    /// Human-readable value (e.g. "active", "19 MB / 128 MB").
23    pub value: String,
24    /// Description of what this check verifies.
25    pub description: String,
26}
27
28/// Check result severity.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Status {
31    Pass,
32    Warn,
33    Fail,
34}
35
36impl std::fmt::Display for Status {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::Pass => write!(f, "PASS"),
40            Self::Warn => write!(f, "WARN"),
41            Self::Fail => write!(f, "FAIL"),
42        }
43    }
44}
45
46/// Known check categories.
47const ALL_CATEGORIES: &[&str] = &["daemon", "memory", "sandbox", "platform"];
48
49/// Run diagnostic checks and produce output.
50pub fn cmd_doctor(
51    categories: &str,
52    output_format: &str,
53    exit_code: bool,
54    quiet: bool,
55) -> anyhow::Result<()> {
56    let selected: Vec<&str> = if categories == "all" {
57        ALL_CATEGORIES.to_vec()
58    } else {
59        categories.split(',').map(str::trim).collect()
60    };
61
62    let mut checks = Vec::new();
63
64    for cat in &selected {
65        match *cat {
66            "daemon" => checks.extend(daemon::checks()),
67            "memory" => checks.extend(memory::checks()),
68            "sandbox" => checks.extend(sandbox::checks()),
69            "platform" => checks.extend(platform::checks()),
70            other => {
71                eprintln!("Unknown doctor category: {other}");
72                eprintln!("Available: {}", ALL_CATEGORIES.join(", "));
73            }
74        }
75    }
76
77    let pass_count = checks.iter().filter(|c| c.status == Status::Pass).count();
78    let warn_count = checks.iter().filter(|c| c.status == Status::Warn).count();
79    let fail_count = checks.iter().filter(|c| c.status == Status::Fail).count();
80
81    match output_format {
82        "json" => print_json(&checks, pass_count, warn_count, fail_count, quiet),
83        _ => print_text(&checks, pass_count, warn_count, fail_count, quiet),
84    }
85
86    if exit_code {
87        if fail_count > 0 {
88            std::process::exit(1);
89        } else if warn_count > 0 {
90            std::process::exit(2);
91        }
92    }
93
94    Ok(())
95}
96
97fn print_text(checks: &[Check], pass: usize, warn: usize, fail: usize, quiet: bool) {
98    if quiet {
99        return;
100    }
101
102    let mut current_category = "";
103    for check in checks {
104        if check.category != current_category {
105            if !current_category.is_empty() {
106                println!();
107            }
108            println!("{}", check.category.to_uppercase().bold());
109            current_category = check.category;
110        }
111
112        let icon = match check.status {
113            Status::Pass => "✓".green().to_string(),
114            Status::Warn => "⚠".yellow().to_string(),
115            Status::Fail => "✗".red().to_string(),
116        };
117
118        let value_display = match check.status {
119            Status::Pass => check.value.green().to_string(),
120            Status::Warn => check.value.yellow().to_string(),
121            Status::Fail => check.value.red().to_string(),
122        };
123
124        println!("  {icon} {:<32} {value_display}", check.id,);
125        if check.status != Status::Pass && !check.description.is_empty() {
126            println!("    {}", check.description.dimmed());
127        }
128    }
129
130    println!();
131    let summary = format!("{pass} passed, {warn} warnings, {fail} failures");
132    if fail > 0 {
133        println!("{}", summary.red());
134    } else if warn > 0 {
135        println!("{}", summary.yellow());
136    } else {
137        println!("{}", summary.green());
138    }
139}
140
141fn print_json(checks: &[Check], pass: usize, warn: usize, fail: usize, quiet: bool) {
142    if quiet {
143        return;
144    }
145
146    let checks_json: Vec<serde_json::Value> = checks
147        .iter()
148        .map(|c| {
149            serde_json::json!({
150                "id": c.id,
151                "category": c.category,
152                "status": c.status.to_string().to_lowercase(),
153                "value": c.value,
154                "description": c.description,
155            })
156        })
157        .collect();
158
159    let output = serde_json::json!({
160        "timestamp": chrono_now(),
161        "version": env!("CARGO_PKG_VERSION"),
162        "summary": { "pass": pass, "warn": warn, "fail": fail },
163        "checks": checks_json,
164    });
165
166    println!(
167        "{}",
168        serde_json::to_string_pretty(&output).unwrap_or_default()
169    );
170}
171
172fn chrono_now() -> String {
173    // Simple ISO 8601 without pulling in chrono crate.
174    let output = std::process::Command::new("date")
175        .arg("-Iseconds")
176        .output()
177        .ok()
178        .and_then(|o| String::from_utf8(o.stdout).ok())
179        .unwrap_or_default();
180    output.trim().to_string()
181}