1use anyhow::Result;
4use std::path::Path;
5
6pub 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
30pub async fn run_quick(config: &crate::config::Config) -> Result<Vec<CheckResult>> {
32 let mut results = Vec::new();
33
34 results.push(check_config(config));
36
37 results.push(check_workspace(&config.data_dir).await);
39
40 results.push(check_sqlite(&config.data_dir));
42
43 results.push(check_model_provider_registry());
45
46 results.push(check_tool_registry(config));
48
49 results.push(check_channel_config(config));
51
52 results.push(check_security_policy(config));
54
55 results.push(check_version());
57
58 Ok(results)
59}
60
61pub async fn run_full(config: &crate::config::Config) -> Result<Vec<CheckResult>> {
63 let mut results = run_quick(config).await?;
64
65 results.push(check_gateway_health(config).await);
67
68 results.push(check_memory_roundtrip(config).await);
70
71 #[cfg(feature = "gateway")]
73 results.push(check_websocket_handshake(config).await);
74
75 Ok(results)
76}
77
78pub 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 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 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 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
248fn 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 "[::]" | "::" => ("[::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 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 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}