Skip to main content

zeroclaw/commands/
self_test.rs

1//! `zeroclaw self-test` — quick and full diagnostic checks.
2
3use anyhow::Result;
4use std::path::Path;
5
6/// Result of a single diagnostic check.
7pub struct CheckResult {
8    pub name: &'static str,
9    pub passed: bool,
10    pub detail: String,
11}
12
13impl CheckResult {
14    fn pass(name: &'static str, detail: impl Into<String>) -> Self {
15        Self {
16            name,
17            passed: true,
18            detail: detail.into(),
19        }
20    }
21    fn fail(name: &'static str, detail: impl Into<String>) -> Self {
22        Self {
23            name,
24            passed: false,
25            detail: detail.into(),
26        }
27    }
28}
29
30/// Run the quick self-test suite (no network required).
31pub async fn run_quick(config: &crate::config::Config) -> Result<Vec<CheckResult>> {
32    let mut results = Vec::new();
33
34    // 1. Config file exists and parses
35    results.push(check_config(config));
36
37    // 2. Workspace directory is writable
38    results.push(check_workspace(&config.data_dir).await);
39
40    // 3. SQLite memory backend opens
41    results.push(check_sqlite(&config.data_dir));
42
43    // 4. ModelProvider registry has entries
44    results.push(check_model_provider_registry());
45
46    // 5. Tool registry has entries
47    results.push(check_tool_registry(config));
48
49    // 6. Channel registry loads
50    results.push(check_channel_config(config));
51
52    // 7. Security policy parses
53    results.push(check_security_policy(config));
54
55    // 8. Version sanity
56    results.push(check_version());
57
58    Ok(results)
59}
60
61/// Run the full self-test suite (includes network checks).
62pub async fn run_full(config: &crate::config::Config) -> Result<Vec<CheckResult>> {
63    let mut results = run_quick(config).await?;
64
65    // 9. Gateway health endpoint
66    results.push(check_gateway_health(config).await);
67
68    // 10. Memory write/read round-trip
69    results.push(check_memory_roundtrip(config).await);
70
71    // 11. WebSocket handshake
72    #[cfg(feature = "gateway")]
73    results.push(check_websocket_handshake(config).await);
74
75    Ok(results)
76}
77
78/// Print results in a formatted table.
79pub fn print_results(results: &[CheckResult]) {
80    let total = results.len();
81    let passed = results.iter().filter(|r| r.passed).count();
82    let failed = total - passed;
83
84    println!();
85    for (i, r) in results.iter().enumerate() {
86        let icon = if r.passed {
87            "\x1b[32m✓\x1b[0m"
88        } else {
89            "\x1b[31m✗\x1b[0m"
90        };
91        println!("  {} {}/{} {} — {}", icon, i + 1, total, r.name, r.detail);
92    }
93    println!();
94    if failed == 0 {
95        println!("  \x1b[32mAll {total} checks passed.\x1b[0m");
96    } else {
97        println!("  \x1b[31m{failed}/{total} checks failed.\x1b[0m");
98    }
99    println!();
100}
101
102fn check_config(config: &crate::config::Config) -> CheckResult {
103    if config.config_path.exists() {
104        CheckResult::pass(
105            "config",
106            format!("loaded from {}", config.config_path.display()),
107        )
108    } else {
109        CheckResult::fail("config", "config file not found (using defaults)")
110    }
111}
112
113async fn check_workspace(workspace_dir: &Path) -> CheckResult {
114    match tokio::fs::metadata(workspace_dir).await {
115        Ok(meta) if meta.is_dir() => {
116            // Try writing a temp file
117            let test_file = workspace_dir.join(".selftest_probe");
118            match tokio::fs::write(&test_file, b"ok").await {
119                Ok(()) => {
120                    let _ = tokio::fs::remove_file(&test_file).await;
121                    CheckResult::pass(
122                        "workspace",
123                        format!("{} (writable)", workspace_dir.display()),
124                    )
125                }
126                Err(e) => CheckResult::fail(
127                    "workspace",
128                    format!("{} (not writable: {e})", workspace_dir.display()),
129                ),
130            }
131        }
132        Ok(_) => CheckResult::fail(
133            "workspace",
134            format!("{} exists but is not a directory", workspace_dir.display()),
135        ),
136        Err(e) => CheckResult::fail(
137            "workspace",
138            format!("{} (error: {e})", workspace_dir.display()),
139        ),
140    }
141}
142
143fn check_sqlite(workspace_dir: &Path) -> CheckResult {
144    let db_path = workspace_dir.join("memory.db");
145    match rusqlite::Connection::open(&db_path) {
146        Ok(conn) => match conn.execute_batch("SELECT 1") {
147            Ok(()) => CheckResult::pass("sqlite", "memory.db opens and responds"),
148            Err(e) => CheckResult::fail("sqlite", format!("query failed: {e}")),
149        },
150        Err(e) => CheckResult::fail("sqlite", format!("cannot open memory.db: {e}")),
151    }
152}
153
154fn check_model_provider_registry() -> CheckResult {
155    let model_providers = crate::providers::list_model_providers();
156    if model_providers.is_empty() {
157        CheckResult::fail("model_providers", "no model providers registered")
158    } else {
159        CheckResult::pass(
160            "model_providers",
161            format!("{} model providers available", model_providers.len()),
162        )
163    }
164}
165
166fn check_tool_registry(config: &crate::config::Config) -> CheckResult {
167    // Probe one tool registry per enabled agent. V3 has no global default —
168    // tools are bound to a specific agent's risk profile.
169    let enabled_agents: Vec<&String> = config
170        .agents
171        .iter()
172        .filter(|(_, a)| a.enabled)
173        .map(|(alias, _)| alias)
174        .collect();
175    if enabled_agents.is_empty() {
176        return CheckResult::fail("tools", "no enabled agents configured");
177    }
178    let mut total_tools = 0usize;
179    for alias in &enabled_agents {
180        let security = match crate::security::SecurityPolicy::for_agent(config, alias) {
181            Ok(p) => std::sync::Arc::new(p),
182            Err(e) => return CheckResult::fail("tools", format!("agent {alias}: {e}")),
183        };
184        let tools = crate::tools::default_tools(security);
185        if tools.is_empty() {
186            return CheckResult::fail("tools", format!("agent {alias}: no tools registered"));
187        }
188        total_tools = tools.len();
189    }
190    CheckResult::pass(
191        "tools",
192        format!(
193            "{} enabled agent(s); {} core tools per registry",
194            enabled_agents.len(),
195            total_tools
196        ),
197    )
198}
199
200fn check_channel_config(config: &crate::config::Config) -> CheckResult {
201    let channels = zeroclaw_channels::listing::compiled_channels(&config.channels);
202    let configured = channels.iter().filter(|e| e.configured).count();
203    CheckResult::pass(
204        "channels",
205        format!(
206            "{} channel types, {} configured",
207            channels.len(),
208            configured
209        ),
210    )
211}
212
213fn check_security_policy(config: &crate::config::Config) -> CheckResult {
214    // Probe the security policy of every enabled agent. V3 binds policy
215    // to risk_profile per agent; there is no global "active" policy.
216    let enabled_agents: Vec<&String> = config
217        .agents
218        .iter()
219        .filter(|(_, a)| a.enabled)
220        .map(|(alias, _)| alias)
221        .collect();
222    if enabled_agents.is_empty() {
223        return CheckResult::fail("security", "no enabled agents configured");
224    }
225    let mut summaries = Vec::new();
226    for alias in &enabled_agents {
227        let Some(profile) = config.risk_profile_for_agent(alias) else {
228            return CheckResult::fail(
229                "security",
230                format!(
231                    "agents.{alias}.risk_profile does not name a configured risk_profiles entry"
232                ),
233            );
234        };
235        if let Err(e) = crate::security::SecurityPolicy::for_agent(config, alias) {
236            return CheckResult::fail("security", format!("agent {alias}: {e}"));
237        }
238        summaries.push(format!("{alias}={:?}", profile.level));
239    }
240    CheckResult::pass("security", summaries.join(", "))
241}
242
243fn check_version() -> CheckResult {
244    let version = env!("CARGO_PKG_VERSION");
245    CheckResult::pass("version", format!("v{version}"))
246}
247
248/// Resolve a wildcard bind address (`0.0.0.0`, `[::]`) to a concrete
249/// loopback target so the probe can actually connect — and report the
250/// configured value alongside so the user isn't confused about why the
251/// output says `127.0.0.1` when their `config.toml` says `0.0.0.0`
252///. Returns `(probe_host, display_host)` where `display_host`
253/// is `Some(_)` only when a rewrite happened.
254fn resolve_probe_host(configured: &str) -> (&str, Option<&str>) {
255    match configured {
256        "0.0.0.0" => ("127.0.0.1", Some("0.0.0.0")),
257        // Normalise both shapes to bracketed form for the display URL so the
258        // unbracketed `::` doesn't yield `http://:::42617` (three colons,
259        // invalid URL). The probe target stays `[::1]`.
260        "[::]" | "::" => ("[::1]", Some("[::]")),
261        other => (other, None),
262    }
263}
264
265fn format_probe_url(scheme: &str, configured_host: &str, port: u16, path: &str) -> String {
266    let (probe_host, display_host) = resolve_probe_host(configured_host);
267    let probed = format!("{scheme}://{probe_host}:{port}{path}");
268    match display_host {
269        Some(cfg) => {
270            format!("{scheme}://{cfg}:{port}{path} (probed via {scheme}://{probe_host}:{port})")
271        }
272        None => probed,
273    }
274}
275
276async fn check_gateway_health(config: &crate::config::Config) -> CheckResult {
277    let port = config.gateway.port;
278    let (probe_host, _) = resolve_probe_host(&config.gateway.host);
279    let probe_url = format!("http://{probe_host}:{port}/health");
280    let display_url = format_probe_url("http", &config.gateway.host, port, "/health");
281    match reqwest::Client::new()
282        .get(&probe_url)
283        .timeout(std::time::Duration::from_secs(5))
284        .send()
285        .await
286    {
287        Ok(resp) if resp.status().is_success() => {
288            CheckResult::pass("gateway", format!("health OK at {display_url}"))
289        }
290        Ok(resp) => CheckResult::fail("gateway", format!("health returned {}", resp.status())),
291        Err(e) => CheckResult::fail("gateway", format!("not reachable at {display_url}: {e}")),
292    }
293}
294
295async fn check_memory_roundtrip(config: &crate::config::Config) -> CheckResult {
296    let mem = match crate::memory::create_memory(
297        &config.memory,
298        &config.data_dir,
299        config
300            .first_model_provider()
301            .and_then(|e| e.api_key.as_deref()),
302    ) {
303        Ok(m) => m,
304        Err(e) => return CheckResult::fail("memory", format!("cannot create backend: {e}")),
305    };
306
307    let test_key = "__selftest_probe__";
308    let test_value = "selftest_ok";
309
310    if let Err(e) = mem
311        .store(
312            test_key,
313            test_value,
314            crate::memory::MemoryCategory::Core,
315            None,
316        )
317        .await
318    {
319        return CheckResult::fail("memory", format!("write failed: {e}"));
320    }
321
322    match mem.recall(test_key, 1, None, None, None).await {
323        Ok(entries) if !entries.is_empty() => {
324            let _ = mem.forget(test_key).await;
325            CheckResult::pass("memory", "write/read/delete round-trip OK")
326        }
327        Ok(_) => {
328            let _ = mem.forget(test_key).await;
329            CheckResult::fail("memory", "no entries returned after round-trip")
330        }
331        Err(e) => {
332            let _ = mem.forget(test_key).await;
333            CheckResult::fail("memory", format!("read failed: {e}"))
334        }
335    }
336}
337
338#[cfg(feature = "gateway")]
339async fn check_websocket_handshake(config: &crate::config::Config) -> CheckResult {
340    let port = config.gateway.port;
341    let (probe_host, _) = resolve_probe_host(&config.gateway.host);
342    let probe_url = format!("ws://{probe_host}:{port}/ws/chat");
343    let display_url = format_probe_url("ws", &config.gateway.host, port, "/ws/chat");
344
345    match tokio_tungstenite::connect_async(&probe_url).await {
346        Ok((_, _)) => CheckResult::pass("websocket", format!("handshake OK at {display_url}")),
347        Err(e) => CheckResult::fail(
348            "websocket",
349            format!("handshake failed at {display_url}: {e}"),
350        ),
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::{format_probe_url, resolve_probe_host};
357
358    #[test]
359    fn resolve_probe_host_ipv4_wildcard() {
360        assert_eq!(
361            resolve_probe_host("0.0.0.0"),
362            ("127.0.0.1", Some("0.0.0.0"))
363        );
364    }
365
366    #[test]
367    fn resolve_probe_host_ipv6_wildcard_bracketed() {
368        assert_eq!(resolve_probe_host("[::]"), ("[::1]", Some("[::]")));
369    }
370
371    #[test]
372    fn resolve_probe_host_ipv6_wildcard_unbracketed_normalises_to_brackets() {
373        // Regression: previously returned `Some("::")`, which `format_probe_url`
374        // would render as `http://:::42617/...` (three colons, invalid URL).
375        assert_eq!(resolve_probe_host("::"), ("[::1]", Some("[::]")));
376    }
377
378    #[test]
379    fn resolve_probe_host_concrete_host_passthrough() {
380        assert_eq!(resolve_probe_host("127.0.0.1"), ("127.0.0.1", None));
381        assert_eq!(
382            resolve_probe_host("example.internal"),
383            ("example.internal", None)
384        );
385    }
386
387    #[test]
388    fn format_probe_url_ipv4_wildcard_shows_both() {
389        assert_eq!(
390            format_probe_url("http", "0.0.0.0", 42617, "/health"),
391            "http://0.0.0.0:42617/health (probed via http://127.0.0.1:42617)"
392        );
393    }
394
395    #[test]
396    fn format_probe_url_ipv6_wildcard_unbracketed_shows_valid_url() {
397        // Regression: was `http://:::42617/health`, now `http://[::]:42617/health`.
398        assert_eq!(
399            format_probe_url("http", "::", 42617, "/health"),
400            "http://[::]:42617/health (probed via http://[::1]:42617)"
401        );
402    }
403
404    #[test]
405    fn format_probe_url_ipv6_wildcard_bracketed_shows_valid_url() {
406        assert_eq!(
407            format_probe_url("http", "[::]", 42617, "/health"),
408            "http://[::]:42617/health (probed via http://[::1]:42617)"
409        );
410    }
411
412    #[test]
413    fn format_probe_url_concrete_host_no_probe_suffix() {
414        assert_eq!(
415            format_probe_url("ws", "127.0.0.1", 42617, "/ws/chat"),
416            "ws://127.0.0.1:42617/ws/chat"
417        );
418    }
419}