Skip to main content

construct/commands/
self_test.rs

1//! `construct 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.workspace_dir).await);
39
40    // 3. SQLite memory backend opens
41    results.push(check_sqlite(&config.workspace_dir));
42
43    // 4. Provider registry has entries
44    results.push(check_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    results.push(check_websocket_handshake(config).await);
73
74    Ok(results)
75}
76
77/// Print results in a formatted table.
78pub fn print_results(results: &[CheckResult]) {
79    let total = results.len();
80    let passed = results.iter().filter(|r| r.passed).count();
81    let failed = total - passed;
82
83    println!();
84    for (i, r) in results.iter().enumerate() {
85        let icon = if r.passed {
86            "\x1b[32m✓\x1b[0m"
87        } else {
88            "\x1b[31m✗\x1b[0m"
89        };
90        println!("  {} {}/{} {} — {}", icon, i + 1, total, r.name, r.detail);
91    }
92    println!();
93    if failed == 0 {
94        println!("  \x1b[32mAll {total} checks passed.\x1b[0m");
95    } else {
96        println!("  \x1b[31m{failed}/{total} checks failed.\x1b[0m");
97    }
98    println!();
99}
100
101fn check_config(config: &crate::config::Config) -> CheckResult {
102    if config.config_path.exists() {
103        CheckResult::pass(
104            "config",
105            format!("loaded from {}", config.config_path.display()),
106        )
107    } else {
108        CheckResult::fail("config", "config file not found (using defaults)")
109    }
110}
111
112async fn check_workspace(workspace_dir: &Path) -> CheckResult {
113    match tokio::fs::metadata(workspace_dir).await {
114        Ok(meta) if meta.is_dir() => {
115            // Try writing a temp file
116            let test_file = workspace_dir.join(".selftest_probe");
117            match tokio::fs::write(&test_file, b"ok").await {
118                Ok(()) => {
119                    let _ = tokio::fs::remove_file(&test_file).await;
120                    CheckResult::pass(
121                        "workspace",
122                        format!("{} (writable)", workspace_dir.display()),
123                    )
124                }
125                Err(e) => CheckResult::fail(
126                    "workspace",
127                    format!("{} (not writable: {e})", workspace_dir.display()),
128                ),
129            }
130        }
131        Ok(_) => CheckResult::fail(
132            "workspace",
133            format!("{} exists but is not a directory", workspace_dir.display()),
134        ),
135        Err(e) => CheckResult::fail(
136            "workspace",
137            format!("{} (error: {e})", workspace_dir.display()),
138        ),
139    }
140}
141
142fn check_sqlite(workspace_dir: &Path) -> CheckResult {
143    let db_path = workspace_dir.join("memory.db");
144    match rusqlite::Connection::open(&db_path) {
145        Ok(conn) => match conn.execute_batch("SELECT 1") {
146            Ok(()) => CheckResult::pass("sqlite", "memory.db opens and responds"),
147            Err(e) => CheckResult::fail("sqlite", format!("query failed: {e}")),
148        },
149        Err(e) => CheckResult::fail("sqlite", format!("cannot open memory.db: {e}")),
150    }
151}
152
153fn check_provider_registry() -> CheckResult {
154    let providers = crate::providers::list_providers();
155    if providers.is_empty() {
156        CheckResult::fail("providers", "no providers registered")
157    } else {
158        CheckResult::pass(
159            "providers",
160            format!("{} providers available", providers.len()),
161        )
162    }
163}
164
165fn check_tool_registry(config: &crate::config::Config) -> CheckResult {
166    let security = std::sync::Arc::new(crate::security::SecurityPolicy::from_config(
167        &config.autonomy,
168        &config.workspace_dir,
169    ));
170    let tools = crate::tools::default_tools(security);
171    if tools.is_empty() {
172        CheckResult::fail("tools", "no tools registered")
173    } else {
174        CheckResult::pass("tools", format!("{} core tools available", tools.len()))
175    }
176}
177
178fn check_channel_config(config: &crate::config::Config) -> CheckResult {
179    let channels = config.channels_config.channels();
180    let configured = channels.iter().filter(|(_, c)| *c).count();
181    CheckResult::pass(
182        "channels",
183        format!(
184            "{} channel types, {} configured",
185            channels.len(),
186            configured
187        ),
188    )
189}
190
191fn check_security_policy(config: &crate::config::Config) -> CheckResult {
192    let _policy =
193        crate::security::SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
194    CheckResult::pass(
195        "security",
196        format!("autonomy level: {:?}", config.autonomy.level),
197    )
198}
199
200fn check_version() -> CheckResult {
201    let version = env!("CARGO_PKG_VERSION");
202    CheckResult::pass("version", format!("v{version}"))
203}
204
205async fn check_gateway_health(config: &crate::config::Config) -> CheckResult {
206    let port = config.gateway.port;
207    let host = if config.gateway.host == "[::]" || config.gateway.host == "0.0.0.0" {
208        "127.0.0.1"
209    } else {
210        &config.gateway.host
211    };
212    let url = format!("http://{host}:{port}/health");
213    match reqwest::Client::new()
214        .get(&url)
215        .timeout(std::time::Duration::from_secs(5))
216        .send()
217        .await
218    {
219        Ok(resp) if resp.status().is_success() => {
220            CheckResult::pass("gateway", format!("health OK at {url}"))
221        }
222        Ok(resp) => CheckResult::fail("gateway", format!("health returned {}", resp.status())),
223        Err(e) => CheckResult::fail("gateway", format!("not reachable at {url}: {e}")),
224    }
225}
226
227async fn check_memory_roundtrip(config: &crate::config::Config) -> CheckResult {
228    let mem = match crate::memory::create_memory(
229        &config.memory,
230        &config.workspace_dir,
231        config.api_key.as_deref(),
232    ) {
233        Ok(m) => m,
234        Err(e) => return CheckResult::fail("memory", format!("cannot create backend: {e}")),
235    };
236
237    let test_key = "__selftest_probe__";
238    let test_value = "selftest_ok";
239
240    if let Err(e) = mem
241        .store(
242            test_key,
243            test_value,
244            crate::memory::MemoryCategory::Core,
245            None,
246        )
247        .await
248    {
249        return CheckResult::fail("memory", format!("write failed: {e}"));
250    }
251
252    match mem.recall(test_key, 1, None, None, None).await {
253        Ok(entries) if !entries.is_empty() => {
254            let _ = mem.forget(test_key).await;
255            CheckResult::pass("memory", "write/read/delete round-trip OK")
256        }
257        Ok(_) => {
258            let _ = mem.forget(test_key).await;
259            CheckResult::fail("memory", "no entries returned after round-trip")
260        }
261        Err(e) => {
262            let _ = mem.forget(test_key).await;
263            CheckResult::fail("memory", format!("read failed: {e}"))
264        }
265    }
266}
267
268async fn check_websocket_handshake(config: &crate::config::Config) -> CheckResult {
269    let port = config.gateway.port;
270    let host = if config.gateway.host == "[::]" || config.gateway.host == "0.0.0.0" {
271        "127.0.0.1"
272    } else {
273        &config.gateway.host
274    };
275    let url = format!("ws://{host}:{port}/ws/chat");
276
277    match tokio_tungstenite::connect_async(&url).await {
278        Ok((_, _)) => CheckResult::pass("websocket", format!("handshake OK at {url}")),
279        Err(e) => CheckResult::fail("websocket", format!("handshake failed at {url}: {e}")),
280    }
281}