1use std::path::Path;
7
8#[derive(Debug)]
10pub struct Check {
11 pub name: String,
12 pub status: CheckStatus,
13 pub detail: String,
14}
15
16#[derive(Debug, PartialEq, Eq)]
17pub enum CheckStatus {
18 Pass,
19 Warn,
20 Fail,
21}
22
23impl Check {
24 fn pass(name: &str, detail: &str) -> Self {
25 Self {
26 name: name.to_string(),
27 status: CheckStatus::Pass,
28 detail: detail.to_string(),
29 }
30 }
31 fn warn(name: &str, detail: &str) -> Self {
32 Self {
33 name: name.to_string(),
34 status: CheckStatus::Warn,
35 detail: detail.to_string(),
36 }
37 }
38 fn fail(name: &str, detail: &str) -> Self {
39 Self {
40 name: name.to_string(),
41 status: CheckStatus::Fail,
42 detail: detail.to_string(),
43 }
44 }
45
46 pub fn symbol(&self) -> &str {
47 match self.status {
48 CheckStatus::Pass => "ok",
49 CheckStatus::Warn => "!?",
50 CheckStatus::Fail => "xx",
51 }
52 }
53}
54
55pub async fn run_all(cwd: &Path, config: &crate::config::Config) -> Vec<Check> {
57 let mut checks = Vec::new();
58
59 for (tool, purpose) in &[
61 ("git", "version control"),
62 ("rg", "content search (ripgrep)"),
63 ("bash", "shell execution"),
64 ] {
65 let available = tokio::process::Command::new("which")
66 .arg(tool)
67 .output()
68 .await
69 .map(|o| o.status.success())
70 .unwrap_or(false);
71
72 if available {
73 checks.push(Check::pass(
74 &format!("tool:{tool}"),
75 &format!("{tool} found ({purpose})"),
76 ));
77 } else {
78 checks.push(Check::fail(
79 &format!("tool:{tool}"),
80 &format!("{tool} not found — needed for {purpose}"),
81 ));
82 }
83 }
84
85 for (tool, purpose) in &[
87 ("node", "JavaScript execution"),
88 ("python3", "Python execution"),
89 ("cargo", "Rust toolchain"),
90 ] {
91 let available = tokio::process::Command::new("which")
92 .arg(tool)
93 .output()
94 .await
95 .map(|o| o.status.success())
96 .unwrap_or(false);
97
98 if available {
99 checks.push(Check::pass(
100 &format!("tool:{tool}"),
101 &format!("{tool} available ({purpose})"),
102 ));
103 } else {
104 checks.push(Check::warn(
105 &format!("tool:{tool}"),
106 &format!("{tool} not found — optional, for {purpose}"),
107 ));
108 }
109 }
110
111 if config.api.api_key.is_some() {
113 checks.push(Check::pass("config:api_key", "API key configured"));
114 } else {
115 checks.push(Check::fail(
116 "config:api_key",
117 "No API key set (AGENT_CODE_API_KEY or --api-key)",
118 ));
119 }
120
121 checks.push(Check::pass(
122 "config:model",
123 &format!("Model: {}", config.api.model),
124 ));
125
126 checks.push(Check::pass(
127 "config:base_url",
128 &format!("API endpoint: {}", config.api.base_url),
129 ));
130
131 if crate::services::git::is_git_repo(cwd).await {
133 let branch = crate::services::git::current_branch(cwd)
134 .await
135 .unwrap_or_else(|| "(detached HEAD)".to_string());
136 checks.push(Check::pass(
137 "git:repo",
138 &format!("Git repository on branch '{branch}'"),
139 ));
140 } else {
141 checks.push(Check::warn("git:repo", "Not inside a git repository"));
142 }
143
144 let user_config = dirs::config_dir().map(|d| d.join("agent-code").join("config.toml"));
146 if let Some(ref path) = user_config {
147 if path.exists() {
148 checks.push(Check::pass(
149 "config:user_file",
150 &format!("User config: {}", path.display()),
151 ));
152 } else {
153 checks.push(Check::warn(
154 "config:user_file",
155 &format!("No user config at {}", path.display()),
156 ));
157 }
158 }
159
160 let project_config = cwd.join(".agent").join("settings.toml");
161 if project_config.exists() {
162 checks.push(Check::pass(
163 "config:project_file",
164 &format!("Project config: {}", project_config.display()),
165 ));
166 }
167
168 let mcp_count = config.mcp_servers.len();
170 if mcp_count > 0 {
171 checks.push(Check::pass(
172 "mcp:servers",
173 &format!("{mcp_count} MCP server(s) configured"),
174 ));
175 }
176
177 if config.api.api_key.is_some() {
179 let url = format!("{}/models", config.api.base_url);
180 match reqwest::Client::new()
181 .get(&url)
182 .timeout(std::time::Duration::from_secs(5))
183 .header(
184 "Authorization",
185 format!("Bearer {}", config.api.api_key.as_deref().unwrap_or("")),
186 )
187 .header("x-api-key", config.api.api_key.as_deref().unwrap_or(""))
188 .send()
189 .await
190 {
191 Ok(resp) => {
192 let status = resp.status();
193 if status.is_success() || status.as_u16() == 200 {
194 checks.push(Check::pass(
195 "api:connectivity",
196 &format!("API reachable ({})", config.api.base_url),
197 ));
198 } else if status.as_u16() == 401 || status.as_u16() == 403 {
199 checks.push(Check::fail(
200 "api:connectivity",
201 &format!("API key rejected (HTTP {})", status.as_u16()),
202 ));
203 } else {
204 checks.push(Check::warn(
205 "api:connectivity",
206 &format!("API responded with HTTP {}", status.as_u16()),
207 ));
208 }
209 }
210 Err(e) => {
211 let msg = if e.is_timeout() {
212 "API unreachable (timeout after 5s)".to_string()
213 } else if e.is_connect() {
214 format!("Cannot connect to {}", config.api.base_url)
215 } else {
216 format!("API error: {e}")
217 };
218 checks.push(Check::fail("api:connectivity", &msg));
219 }
220 }
221 }
222
223 for (name, entry) in &config.mcp_servers {
225 if let Some(ref cmd) = entry.command {
226 let binary = cmd.split_whitespace().next().unwrap_or(cmd);
228 if let Ok(output) = tokio::process::Command::new("which")
229 .arg(binary)
230 .output()
231 .await
232 {
233 if output.status.success() {
234 checks.push(Check::pass(
235 &format!("mcp:{name}"),
236 &format!("MCP server '{name}' binary found: {binary}"),
237 ));
238 } else {
239 checks.push(Check::fail(
240 &format!("mcp:{name}"),
241 &format!("MCP server '{name}' binary not found: {binary}"),
242 ));
243 }
244 }
245 } else if let Some(ref url) = entry.url {
246 match reqwest::Client::new()
248 .get(url)
249 .timeout(std::time::Duration::from_secs(3))
250 .send()
251 .await
252 {
253 Ok(_) => {
254 checks.push(Check::pass(
255 &format!("mcp:{name}"),
256 &format!("MCP server '{name}' reachable at {url}"),
257 ));
258 }
259 Err(_) => {
260 checks.push(Check::fail(
261 &format!("mcp:{name}"),
262 &format!("MCP server '{name}' unreachable at {url}"),
263 ));
264 }
265 }
266 }
267 }
268
269 if let Ok(output) = tokio::process::Command::new("df")
272 .args(["-BG", "."])
273 .current_dir(cwd)
274 .output()
275 .await
276 {
277 let text = String::from_utf8_lossy(&output.stdout);
278 if let Some(line) = text.lines().nth(1) {
280 let parts: Vec<&str> = line.split_whitespace().collect();
281 if let Some(avail) = parts.get(3) {
282 let gb: f64 = avail.trim_end_matches('G').parse().unwrap_or(999.0);
283 if gb < 1.0 {
284 checks.push(Check::warn(
285 "disk:space",
286 &format!("Low disk space: {avail} available"),
287 ));
288 }
289 }
290 }
291 }
292
293 checks
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_check_constructors() {
302 let p = Check::pass("test", "ok");
303 assert_eq!(p.status, CheckStatus::Pass);
304 assert_eq!(p.symbol(), "ok");
305
306 let w = Check::warn("test", "warning");
307 assert_eq!(w.status, CheckStatus::Warn);
308 assert_eq!(w.symbol(), "!?");
309
310 let f = Check::fail("test", "failed");
311 assert_eq!(f.status, CheckStatus::Fail);
312 assert_eq!(f.symbol(), "xx");
313 }
314
315 #[test]
316 fn test_check_fields() {
317 let c = Check::pass("git:repo", "Git repository on branch 'main'");
318 assert_eq!(c.name, "git:repo");
319 assert!(c.detail.contains("main"));
320 }
321
322 #[tokio::test]
323 async fn test_run_all_returns_checks() {
324 let dir = tempfile::tempdir().unwrap();
325 let config = crate::config::Config::default();
326 let checks = run_all(dir.path(), &config).await;
327
328 assert!(checks.len() >= 3);
330
331 assert!(checks.iter().any(|c| c.name.starts_with("tool:")));
333 }
334
335 #[tokio::test]
336 async fn test_run_all_in_git_repo() {
337 let dir = tempfile::tempdir().unwrap();
338 tokio::process::Command::new("git")
339 .args(["init", "-q"])
340 .current_dir(dir.path())
341 .output()
342 .await
343 .unwrap();
344
345 let config = crate::config::Config::default();
346 let checks = run_all(dir.path(), &config).await;
347
348 let git_check = checks.iter().find(|c| c.name == "git:repo");
349 assert!(git_check.is_some());
350 assert_eq!(git_check.unwrap().status, CheckStatus::Pass);
351 }
352
353 #[tokio::test]
354 async fn test_run_all_no_api_key() {
355 let dir = tempfile::tempdir().unwrap();
356 let mut config = crate::config::Config::default();
357 config.api.api_key = None;
358
359 let checks = run_all(dir.path(), &config).await;
360
361 let api_check = checks.iter().find(|c| c.name == "config:api_key");
362 assert!(api_check.is_some());
363 assert_eq!(api_check.unwrap().status, CheckStatus::Fail);
364 }
365
366 #[tokio::test]
367 async fn test_run_all_with_api_key() {
368 let dir = tempfile::tempdir().unwrap();
369 let mut config = crate::config::Config::default();
370 config.api.api_key = Some("test-key".to_string());
371
372 let checks = run_all(dir.path(), &config).await;
373
374 let api_check = checks.iter().find(|c| c.name == "config:api_key");
375 assert!(api_check.is_some());
376 assert_eq!(api_check.unwrap().status, CheckStatus::Pass);
377 }
378
379 #[tokio::test]
380 async fn test_run_all_mcp_servers() {
381 let dir = tempfile::tempdir().unwrap();
382 let mut config = crate::config::Config::default();
383 config.mcp_servers.insert(
384 "test-server".to_string(),
385 crate::config::McpServerEntry {
386 command: Some("nonexistent-binary-xyz".to_string()),
387 args: vec![],
388 url: None,
389 env: std::collections::HashMap::new(),
390 },
391 );
392
393 let checks = run_all(dir.path(), &config).await;
394
395 let mcp_check = checks.iter().find(|c| c.name == "mcp:test-server");
397 assert!(mcp_check.is_some());
398 assert_eq!(mcp_check.unwrap().status, CheckStatus::Fail);
400 }
401}