1use anyhow::Result;
4use std::path::Path;
5use zeroclaw_runtime::i18n::get_required_cli_string_with_args;
6
7pub struct CheckResult {
9 pub name: &'static str,
10 pub passed: bool,
11 pub detail: String,
12}
13
14impl CheckResult {
15 fn pass(name: &'static str, detail: impl Into<String>) -> Self {
16 Self {
17 name,
18 passed: true,
19 detail: detail.into(),
20 }
21 }
22 fn fail(name: &'static str, detail: impl Into<String>) -> Self {
23 Self {
24 name,
25 passed: false,
26 detail: detail.into(),
27 }
28 }
29}
30
31pub async fn run_quick(config: &crate::config::Config) -> Result<Vec<CheckResult>> {
33 let mut results = Vec::new();
34
35 results.push(check_config(config));
37
38 results.push(check_workspace(&config.data_dir).await);
40
41 results.push(check_sqlite(&config.data_dir));
43
44 results.push(check_model_provider_registry());
46
47 results.push(check_tool_registry(config));
49
50 results.push(check_channel_config(config));
52
53 results.push(check_security_policy(config));
55
56 results.push(check_version());
58
59 results.push(check_web_dist_dir(config));
61
62 Ok(results)
63}
64
65pub async fn run_full(config: &crate::config::Config) -> Result<Vec<CheckResult>> {
67 let mut results = run_quick(config).await?;
68
69 results.push(check_gateway_health(config).await);
71
72 results.push(check_memory_roundtrip(config).await);
74
75 #[cfg(feature = "gateway")]
77 results.push(check_websocket_handshake(config).await);
78
79 Ok(results)
80}
81
82pub fn print_results(results: &[CheckResult]) {
84 let total = results.len();
85 let passed = results.iter().filter(|r| r.passed).count();
86 let failed = total - passed;
87
88 println!();
89 for (i, r) in results.iter().enumerate() {
90 let icon = if r.passed {
91 "\x1b[32m✓\x1b[0m"
92 } else {
93 "\x1b[31m✗\x1b[0m"
94 };
95 println!(" {} {}/{} {} — {}", icon, i + 1, total, r.name, r.detail);
96 }
97 println!();
98 if failed == 0 {
99 println!(
100 " \x1b[32m{}\x1b[0m",
101 get_required_cli_string_with_args(
102 "cli-selftest-all-passed",
103 &[("total", &total.to_string())]
104 )
105 );
106 } else {
107 println!(
108 " \x1b[31m{}\x1b[0m",
109 get_required_cli_string_with_args(
110 "cli-selftest-some-failed",
111 &[
112 ("failed", &failed.to_string()),
113 ("total", &total.to_string())
114 ],
115 )
116 );
117 }
118 println!();
119}
120
121fn check_config(config: &crate::config::Config) -> CheckResult {
122 if config.config_path.exists() {
123 CheckResult::pass(
124 "config",
125 format!("loaded from {}", config.config_path.display()),
126 )
127 } else {
128 CheckResult::fail("config", "config file not found (using defaults)")
129 }
130}
131
132async fn check_workspace(workspace_dir: &Path) -> CheckResult {
133 match tokio::fs::metadata(workspace_dir).await {
134 Ok(meta) if meta.is_dir() => {
135 let test_file = workspace_dir.join(".selftest_probe");
137 match tokio::fs::write(&test_file, b"ok").await {
138 Ok(()) => {
139 let _ = tokio::fs::remove_file(&test_file).await;
140 CheckResult::pass(
141 "workspace",
142 format!("{} (writable)", workspace_dir.display()),
143 )
144 }
145 Err(e) => CheckResult::fail(
146 "workspace",
147 format!("{} (not writable: {e})", workspace_dir.display()),
148 ),
149 }
150 }
151 Ok(_) => CheckResult::fail(
152 "workspace",
153 format!("{} exists but is not a directory", workspace_dir.display()),
154 ),
155 Err(e) => CheckResult::fail(
156 "workspace",
157 format!("{} (error: {e})", workspace_dir.display()),
158 ),
159 }
160}
161
162fn check_sqlite(workspace_dir: &Path) -> CheckResult {
163 let db_path = workspace_dir.join("memory.db");
164 match rusqlite::Connection::open(&db_path) {
165 Ok(conn) => match conn.execute_batch("SELECT 1") {
166 Ok(()) => CheckResult::pass("sqlite", "memory.db opens and responds"),
167 Err(e) => CheckResult::fail("sqlite", format!("query failed: {e}")),
168 },
169 Err(e) => CheckResult::fail("sqlite", format!("cannot open memory.db: {e}")),
170 }
171}
172
173fn check_model_provider_registry() -> CheckResult {
174 let model_providers = crate::providers::list_model_providers();
175 if model_providers.is_empty() {
176 CheckResult::fail("model_providers", "no model providers registered")
177 } else {
178 CheckResult::pass(
179 "model_providers",
180 format!("{} model providers available", model_providers.len()),
181 )
182 }
183}
184
185fn check_tool_registry(config: &crate::config::Config) -> CheckResult {
186 let enabled_agents: Vec<&String> = config
189 .agents
190 .iter()
191 .filter(|(_, a)| a.enabled)
192 .map(|(alias, _)| alias)
193 .collect();
194 if enabled_agents.is_empty() {
195 return CheckResult::fail("tools", "no enabled agents configured");
196 }
197 let mut total_tools = 0usize;
198 for alias in &enabled_agents {
199 let security = match crate::security::SecurityPolicy::for_agent(config, alias) {
200 Ok(p) => std::sync::Arc::new(p),
201 Err(e) => return CheckResult::fail("tools", format!("agent {alias}: {e}")),
202 };
203 let tools = crate::tools::default_tools(security);
204 if tools.is_empty() {
205 return CheckResult::fail("tools", format!("agent {alias}: no tools registered"));
206 }
207 total_tools = tools.len();
208 }
209 CheckResult::pass(
210 "tools",
211 format!(
212 "{} enabled agent(s); {} core tools per registry",
213 enabled_agents.len(),
214 total_tools
215 ),
216 )
217}
218
219fn check_channel_config(config: &crate::config::Config) -> CheckResult {
220 let channels = zeroclaw_channels::listing::compiled_channels(&config.channels);
221 let configured = channels.iter().filter(|e| e.configured).count();
222 CheckResult::pass(
223 "channels",
224 format!(
225 "{} channel types, {} configured",
226 channels.len(),
227 configured
228 ),
229 )
230}
231
232fn check_security_policy(config: &crate::config::Config) -> CheckResult {
233 let enabled_agents: Vec<&String> = config
236 .agents
237 .iter()
238 .filter(|(_, a)| a.enabled)
239 .map(|(alias, _)| alias)
240 .collect();
241 if enabled_agents.is_empty() {
242 return CheckResult::fail("security", "no enabled agents configured");
243 }
244 let mut summaries = Vec::new();
245 for alias in &enabled_agents {
246 let Some(profile) = config.risk_profile_for_agent(alias) else {
247 return CheckResult::fail(
248 "security",
249 format!(
250 "agents.{alias}.risk_profile does not name a configured risk_profiles entry"
251 ),
252 );
253 };
254 if let Err(e) = crate::security::SecurityPolicy::for_agent(config, alias) {
255 return CheckResult::fail("security", format!("agent {alias}: {e}"));
256 }
257 summaries.push(format!("{alias}={:?}", profile.level));
258 }
259 CheckResult::pass("security", summaries.join(", "))
260}
261
262fn check_version() -> CheckResult {
263 let version = env!("CARGO_PKG_VERSION");
264 CheckResult::pass("version", format!("v{version}"))
265}
266
267fn check_web_dist_dir(config: &crate::config::Config) -> CheckResult {
281 let name = web_dist_dir_check_name();
282 match config.gateway.web_dist_dir.as_deref() {
283 None => CheckResult::pass(
284 name,
285 zeroclaw_runtime::i18n::get_required_cli_string(
286 "cli-self-test-web-dist-dir-pass-unset",
287 ),
288 ),
289 Some(value) => match web_dist_dir_expansion_reason_key(value) {
290 None => CheckResult::pass(
291 name,
292 zeroclaw_runtime::i18n::get_required_cli_string_with_args(
293 "cli-self-test-web-dist-dir-pass-literal",
294 &[("path", value)],
295 ),
296 ),
297 Some(reason_key) => {
298 let reason = zeroclaw_runtime::i18n::get_required_cli_string(reason_key);
299 CheckResult::fail(
300 name,
301 zeroclaw_runtime::i18n::get_required_cli_string_with_args(
302 "cli-self-test-web-dist-dir-fail-expansion",
303 &[("path", value), ("reason", reason.as_str())],
304 ),
305 )
306 }
307 },
308 }
309}
310
311fn web_dist_dir_check_name() -> &'static str {
316 use std::sync::OnceLock;
317 static CACHED: OnceLock<&'static str> = OnceLock::new();
318 CACHED.get_or_init(|| {
319 let resolved =
320 zeroclaw_runtime::i18n::get_required_cli_string("cli-self-test-web-dist-dir-name");
321 Box::leak(resolved.into_boxed_str())
322 })
323}
324
325fn web_dist_dir_expansion_reason_key(value: &str) -> Option<&'static str> {
329 if value.starts_with('~') {
330 Some("cli-web-dist-dir-reason-tilde")
331 } else if value.contains('$') {
332 Some("cli-web-dist-dir-reason-dollar")
333 } else {
334 None
335 }
336}
337
338fn resolve_probe_host(configured: &str) -> (&str, Option<&str>) {
345 match configured {
346 "0.0.0.0" => ("127.0.0.1", Some("0.0.0.0")),
347 "[::]" | "::" => ("[::1]", Some("[::]")),
351 other => (other, None),
352 }
353}
354
355fn format_probe_url(scheme: &str, configured_host: &str, port: u16, path: &str) -> String {
356 let (probe_host, display_host) = resolve_probe_host(configured_host);
357 let probed = format!("{scheme}://{probe_host}:{port}{path}");
358 match display_host {
359 Some(cfg) => {
360 format!("{scheme}://{cfg}:{port}{path} (probed via {scheme}://{probe_host}:{port})")
361 }
362 None => probed,
363 }
364}
365
366async fn check_gateway_health(config: &crate::config::Config) -> CheckResult {
367 let port = config.gateway.port;
368 let (probe_host, _) = resolve_probe_host(&config.gateway.host);
369 let probe_url = format!("http://{probe_host}:{port}/health");
370 let display_url = format_probe_url("http", &config.gateway.host, port, "/health");
371 match reqwest::Client::new()
372 .get(&probe_url)
373 .timeout(std::time::Duration::from_secs(5))
374 .send()
375 .await
376 {
377 Ok(resp) if resp.status().is_success() => {
378 CheckResult::pass("gateway", format!("health OK at {display_url}"))
379 }
380 Ok(resp) => CheckResult::fail("gateway", format!("health returned {}", resp.status())),
381 Err(e) => CheckResult::fail("gateway", format!("not reachable at {display_url}: {e}")),
382 }
383}
384
385async fn check_memory_roundtrip(config: &crate::config::Config) -> CheckResult {
386 let mem = match crate::memory::create_memory(&config.memory, &config.data_dir, None) {
387 Ok(m) => m,
388 Err(e) => return CheckResult::fail("memory", format!("cannot create backend: {e}")),
389 };
390
391 let test_key = "__selftest_probe__";
392 let test_value = "selftest_ok";
393
394 if let Err(e) = mem
395 .store(
396 test_key,
397 test_value,
398 crate::memory::MemoryCategory::Core,
399 None,
400 )
401 .await
402 {
403 return CheckResult::fail("memory", format!("write failed: {e}"));
404 }
405
406 match mem.recall(test_key, 1, None, None, None).await {
407 Ok(entries) if !entries.is_empty() => {
408 let _ = mem.forget(test_key).await;
409 CheckResult::pass("memory", "write/read/delete round-trip OK")
410 }
411 Ok(_) => {
412 let _ = mem.forget(test_key).await;
413 CheckResult::fail("memory", "no entries returned after round-trip")
414 }
415 Err(e) => {
416 let _ = mem.forget(test_key).await;
417 CheckResult::fail("memory", format!("read failed: {e}"))
418 }
419 }
420}
421
422#[cfg(feature = "gateway")]
423async fn check_websocket_handshake(config: &crate::config::Config) -> CheckResult {
424 let port = config.gateway.port;
425 let (probe_host, _) = resolve_probe_host(&config.gateway.host);
426 let probe_url = format!("ws://{probe_host}:{port}/ws/chat");
427 let display_url = format_probe_url("ws", &config.gateway.host, port, "/ws/chat");
428
429 match tokio_tungstenite::connect_async(&probe_url).await {
430 Ok((_, _)) => CheckResult::pass("websocket", format!("handshake OK at {display_url}")),
431 Err(e) => CheckResult::fail(
432 "websocket",
433 format!("handshake failed at {display_url}: {e}"),
434 ),
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::{format_probe_url, resolve_probe_host, web_dist_dir_expansion_reason_key};
441
442 #[test]
443 fn web_dist_dir_with_tilde_resolves_to_tilde_reason_key() {
444 assert_eq!(
447 web_dist_dir_expansion_reason_key("~/web-dist"),
448 Some("cli-web-dist-dir-reason-tilde")
449 );
450 assert_eq!(
451 web_dist_dir_expansion_reason_key("~"),
452 Some("cli-web-dist-dir-reason-tilde")
453 );
454 }
455
456 #[test]
457 fn web_dist_dir_with_env_var_resolves_to_dollar_reason_key() {
458 assert_eq!(
460 web_dist_dir_expansion_reason_key("$HOME/web-dist"),
461 Some("cli-web-dist-dir-reason-dollar")
462 );
463 assert_eq!(
464 web_dist_dir_expansion_reason_key("${HOME}/web-dist"),
465 Some("cli-web-dist-dir-reason-dollar")
466 );
467 assert_eq!(
468 web_dist_dir_expansion_reason_key("/srv/$USER/dist"),
469 Some("cli-web-dist-dir-reason-dollar")
470 );
471 assert!(web_dist_dir_expansion_reason_key("/srv/zeroclaw/web-dist").is_none());
473 assert!(web_dist_dir_expansion_reason_key("./dist").is_none());
474 }
475
476 #[test]
477 fn check_web_dist_dir_emits_localized_fail_for_tilde() {
478 let mut config = crate::config::Config::default();
482 config.gateway.web_dist_dir = Some("~/web-dist".to_string());
483
484 let result = super::check_web_dist_dir(&config);
485 assert!(!result.passed, "tilde path must fail the check");
486
487 let expected_reason =
488 zeroclaw_runtime::i18n::get_required_cli_string("cli-web-dist-dir-reason-tilde");
489 let expected_detail = zeroclaw_runtime::i18n::get_required_cli_string_with_args(
490 "cli-self-test-web-dist-dir-fail-expansion",
491 &[("path", "~/web-dist"), ("reason", expected_reason.as_str())],
492 );
493 assert_eq!(result.detail, expected_detail);
494
495 let expected_name =
496 zeroclaw_runtime::i18n::get_required_cli_string("cli-self-test-web-dist-dir-name");
497 assert_eq!(result.name, expected_name.as_str());
498 }
499
500 #[test]
501 fn check_web_dist_dir_emits_localized_pass_for_literal() {
502 let mut config = crate::config::Config::default();
503 config.gateway.web_dist_dir = Some("/srv/zeroclaw/web-dist".to_string());
504
505 let result = super::check_web_dist_dir(&config);
506 assert!(result.passed);
507
508 let expected_detail = zeroclaw_runtime::i18n::get_required_cli_string_with_args(
509 "cli-self-test-web-dist-dir-pass-literal",
510 &[("path", "/srv/zeroclaw/web-dist")],
511 );
512 assert_eq!(result.detail, expected_detail);
513 }
514
515 #[test]
516 fn check_web_dist_dir_emits_localized_pass_when_unset() {
517 let config = crate::config::Config::default();
518 let result = super::check_web_dist_dir(&config);
519 assert!(result.passed);
520
521 let expected_detail = zeroclaw_runtime::i18n::get_required_cli_string(
522 "cli-self-test-web-dist-dir-pass-unset",
523 );
524 assert_eq!(result.detail, expected_detail);
525 }
526
527 #[test]
528 fn resolve_probe_host_ipv4_wildcard() {
529 assert_eq!(
530 resolve_probe_host("0.0.0.0"),
531 ("127.0.0.1", Some("0.0.0.0"))
532 );
533 }
534
535 #[test]
536 fn resolve_probe_host_ipv6_wildcard_bracketed() {
537 assert_eq!(resolve_probe_host("[::]"), ("[::1]", Some("[::]")));
538 }
539
540 #[test]
541 fn resolve_probe_host_ipv6_wildcard_unbracketed_normalises_to_brackets() {
542 assert_eq!(resolve_probe_host("::"), ("[::1]", Some("[::]")));
545 }
546
547 #[test]
548 fn resolve_probe_host_concrete_host_passthrough() {
549 assert_eq!(resolve_probe_host("127.0.0.1"), ("127.0.0.1", None));
550 assert_eq!(
551 resolve_probe_host("example.internal"),
552 ("example.internal", None)
553 );
554 }
555
556 #[test]
557 fn format_probe_url_ipv4_wildcard_shows_both() {
558 assert_eq!(
559 format_probe_url("http", "0.0.0.0", 42617, "/health"),
560 "http://0.0.0.0:42617/health (probed via http://127.0.0.1:42617)"
561 );
562 }
563
564 #[test]
565 fn format_probe_url_ipv6_wildcard_unbracketed_shows_valid_url() {
566 assert_eq!(
568 format_probe_url("http", "::", 42617, "/health"),
569 "http://[::]:42617/health (probed via http://[::1]:42617)"
570 );
571 }
572
573 #[test]
574 fn format_probe_url_ipv6_wildcard_bracketed_shows_valid_url() {
575 assert_eq!(
576 format_probe_url("http", "[::]", 42617, "/health"),
577 "http://[::]:42617/health (probed via http://[::1]:42617)"
578 );
579 }
580
581 #[test]
582 fn format_probe_url_concrete_host_no_probe_suffix() {
583 assert_eq!(
584 format_probe_url("ws", "127.0.0.1", 42617, "/ws/chat"),
585 "ws://127.0.0.1:42617/ws/chat"
586 );
587 }
588}