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 let provider_kind =
179 crate::llm::provider::detect_provider(&config.api.model, &config.api.base_url);
180 checks.push(Check::pass(
181 "provider:detected",
182 &format!("Provider: {provider_kind:?}"),
183 ));
184
185 if let Some(expected_env) = match provider_kind {
186 crate::llm::provider::ProviderKind::AzureOpenAi => Some("AZURE_OPENAI_API_KEY"),
187 crate::llm::provider::ProviderKind::Bedrock => Some("AWS_REGION"),
188 crate::llm::provider::ProviderKind::Vertex => Some("GOOGLE_CLOUD_PROJECT"),
189 _ => None,
190 } {
191 if std::env::var(expected_env).is_ok() {
192 checks.push(Check::pass(
193 "provider:env",
194 &format!("{expected_env} is set"),
195 ));
196 } else {
197 checks.push(Check::warn(
198 "provider:env",
199 &format!("{expected_env} not set (may be needed for {provider_kind:?})"),
200 ));
201 }
202 }
203
204 if config.api.api_key.is_some() {
206 let api_key = config.api.api_key.as_deref().unwrap_or("");
207 let url = format!("{}/models", config.api.base_url);
208
209 let client = reqwest::Client::new();
210 let mut request = client.get(&url).timeout(std::time::Duration::from_secs(5));
211
212 match provider_kind {
214 crate::llm::provider::ProviderKind::AzureOpenAi => {
215 request = request.header("api-key", api_key);
216 }
217 crate::llm::provider::ProviderKind::Anthropic
218 | crate::llm::provider::ProviderKind::Bedrock
219 | crate::llm::provider::ProviderKind::Vertex => {
220 request = request
221 .header("x-api-key", api_key)
222 .header("anthropic-version", "2023-06-01");
223 }
224 _ => {
225 request = request.header("Authorization", format!("Bearer {api_key}"));
226 }
227 }
228
229 match request.send().await {
230 Ok(resp) => {
231 let status = resp.status();
232 if status.is_success() || status.as_u16() == 200 {
233 checks.push(Check::pass(
234 "api:connectivity",
235 &format!(
236 "API reachable ({:?} at {})",
237 provider_kind, config.api.base_url
238 ),
239 ));
240 } else if status.as_u16() == 401 || status.as_u16() == 403 {
241 checks.push(Check::fail(
242 "api:connectivity",
243 &format!(
244 "API key rejected by {:?} (HTTP {})",
245 provider_kind,
246 status.as_u16()
247 ),
248 ));
249 } else {
250 checks.push(Check::warn(
251 "api:connectivity",
252 &format!(
253 "{:?} responded with HTTP {}",
254 provider_kind,
255 status.as_u16()
256 ),
257 ));
258 }
259 }
260 Err(e) => {
261 let msg = if e.is_timeout() {
262 format!("{:?} unreachable (timeout after 5s)", provider_kind)
263 } else if e.is_connect() {
264 format!(
265 "Cannot connect to {:?} at {}",
266 provider_kind, config.api.base_url
267 )
268 } else {
269 format!("{:?} error: {e}", provider_kind)
270 };
271 checks.push(Check::fail("api:connectivity", &msg));
272 }
273 }
274 }
275
276 for (name, entry) in &config.mcp_servers {
278 if let Some(ref cmd) = entry.command {
279 let binary = cmd.split_whitespace().next().unwrap_or(cmd);
281 if let Ok(output) = tokio::process::Command::new("which")
282 .arg(binary)
283 .output()
284 .await
285 {
286 if output.status.success() {
287 checks.push(Check::pass(
288 &format!("mcp:{name}"),
289 &format!("MCP server '{name}' binary found: {binary}"),
290 ));
291 } else {
292 checks.push(Check::fail(
293 &format!("mcp:{name}"),
294 &format!("MCP server '{name}' binary not found: {binary}"),
295 ));
296 }
297 }
298 } else if let Some(ref url) = entry.url {
299 match reqwest::Client::new()
301 .get(url)
302 .timeout(std::time::Duration::from_secs(3))
303 .send()
304 .await
305 {
306 Ok(_) => {
307 checks.push(Check::pass(
308 &format!("mcp:{name}"),
309 &format!("MCP server '{name}' reachable at {url}"),
310 ));
311 }
312 Err(_) => {
313 checks.push(Check::fail(
314 &format!("mcp:{name}"),
315 &format!("MCP server '{name}' unreachable at {url}"),
316 ));
317 }
318 }
319 }
320 }
321
322 if let Ok(output) = tokio::process::Command::new("df")
325 .args(["-BG", "."])
326 .current_dir(cwd)
327 .output()
328 .await
329 {
330 let text = String::from_utf8_lossy(&output.stdout);
331 if let Some(line) = text.lines().nth(1) {
333 let parts: Vec<&str> = line.split_whitespace().collect();
334 if let Some(avail) = parts.get(3) {
335 let gb: f64 = avail.trim_end_matches('G').parse().unwrap_or(999.0);
336 if gb < 1.0 {
337 checks.push(Check::warn(
338 "disk:space",
339 &format!("Low disk space: {avail} available"),
340 ));
341 }
342 }
343 }
344 }
345
346 checks
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn test_check_constructors() {
355 let p = Check::pass("test", "ok");
356 assert_eq!(p.status, CheckStatus::Pass);
357 assert_eq!(p.symbol(), "ok");
358
359 let w = Check::warn("test", "warning");
360 assert_eq!(w.status, CheckStatus::Warn);
361 assert_eq!(w.symbol(), "!?");
362
363 let f = Check::fail("test", "failed");
364 assert_eq!(f.status, CheckStatus::Fail);
365 assert_eq!(f.symbol(), "xx");
366 }
367
368 #[test]
369 fn test_check_fields() {
370 let c = Check::pass("git:repo", "Git repository on branch 'main'");
371 assert_eq!(c.name, "git:repo");
372 assert!(c.detail.contains("main"));
373 }
374
375 #[tokio::test]
376 async fn test_run_all_returns_checks() {
377 let dir = tempfile::tempdir().unwrap();
378 let config = crate::config::Config::default();
379 let checks = run_all(dir.path(), &config).await;
380
381 assert!(checks.len() >= 3);
383
384 assert!(checks.iter().any(|c| c.name.starts_with("tool:")));
386 }
387
388 #[tokio::test]
389 async fn test_run_all_in_git_repo() {
390 let dir = tempfile::tempdir().unwrap();
391 tokio::process::Command::new("git")
392 .args(["init", "-q"])
393 .current_dir(dir.path())
394 .output()
395 .await
396 .unwrap();
397
398 let config = crate::config::Config::default();
399 let checks = run_all(dir.path(), &config).await;
400
401 let git_check = checks.iter().find(|c| c.name == "git:repo");
402 assert!(git_check.is_some());
403 assert_eq!(git_check.unwrap().status, CheckStatus::Pass);
404 }
405
406 #[tokio::test]
407 async fn test_run_all_no_api_key() {
408 let dir = tempfile::tempdir().unwrap();
409 let mut config = crate::config::Config::default();
410 config.api.api_key = None;
411
412 let checks = run_all(dir.path(), &config).await;
413
414 let api_check = checks.iter().find(|c| c.name == "config:api_key");
415 assert!(api_check.is_some());
416 assert_eq!(api_check.unwrap().status, CheckStatus::Fail);
417 }
418
419 #[tokio::test]
420 async fn test_run_all_with_api_key() {
421 let dir = tempfile::tempdir().unwrap();
422 let mut config = crate::config::Config::default();
423 config.api.api_key = Some("test-key".to_string());
424
425 let checks = run_all(dir.path(), &config).await;
426
427 let api_check = checks.iter().find(|c| c.name == "config:api_key");
428 assert!(api_check.is_some());
429 assert_eq!(api_check.unwrap().status, CheckStatus::Pass);
430 }
431
432 #[tokio::test]
433 async fn test_run_all_includes_provider_check() {
434 let dir = tempfile::tempdir().unwrap();
435 let mut config = crate::config::Config::default();
436 config.api.base_url = "https://api.openai.com/v1".to_string();
437 config.api.model = "gpt-5.4".to_string();
438
439 let checks = run_all(dir.path(), &config).await;
440
441 let provider_check = checks.iter().find(|c| c.name == "provider:detected");
442 assert!(provider_check.is_some());
443 assert_eq!(provider_check.unwrap().status, CheckStatus::Pass);
444 assert!(provider_check.unwrap().detail.contains("OpenAi"));
445 }
446
447 #[tokio::test]
448 async fn test_run_all_azure_provider_env_check() {
449 let dir = tempfile::tempdir().unwrap();
450 let mut config = crate::config::Config::default();
451 config.api.base_url =
452 "https://myresource.openai.azure.com/openai/deployments/gpt-4".to_string();
453
454 let checks = run_all(dir.path(), &config).await;
455
456 let provider_check = checks.iter().find(|c| c.name == "provider:detected");
457 assert!(provider_check.is_some());
458 assert!(provider_check.unwrap().detail.contains("AzureOpenAi"));
459
460 let env_check = checks.iter().find(|c| c.name == "provider:env");
462 assert!(env_check.is_some());
463 }
464
465 #[tokio::test]
466 async fn test_run_all_mcp_servers() {
467 let dir = tempfile::tempdir().unwrap();
468 let mut config = crate::config::Config::default();
469 config.mcp_servers.insert(
470 "test-server".to_string(),
471 crate::config::McpServerEntry {
472 command: Some("nonexistent-binary-xyz".to_string()),
473 args: vec![],
474 url: None,
475 env: std::collections::HashMap::new(),
476 },
477 );
478
479 let checks = run_all(dir.path(), &config).await;
480
481 let mcp_check = checks.iter().find(|c| c.name == "mcp:test-server");
483 assert!(mcp_check.is_some());
484 assert_eq!(mcp_check.unwrap().status, CheckStatus::Fail);
486 }
487}