Skip to main content

agent_code_lib/services/
diagnostics.rs

1//! Environment diagnostics.
2//!
3//! Comprehensive checks for the agent's runtime environment:
4//! tools, configuration, connectivity, and permissions.
5
6use std::path::Path;
7
8/// Result of a single diagnostic check.
9#[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
55/// Run all diagnostic checks and return results.
56pub async fn run_all(cwd: &Path, config: &crate::config::Config) -> Vec<Check> {
57    let mut checks = Vec::new();
58
59    // 1. Required CLI tools.
60    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    // 2. Optional tools.
86    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    // 3. API configuration.
112    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    // 4. Git repository.
132    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    // 5. Config file locations.
145    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    // 6. MCP servers.
169    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    // 7. Provider detection and health check.
178    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    // 8. API connectivity test (provider-aware auth).
205    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        // Use provider-specific auth headers.
213        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    // 9. MCP server health check.
277    for (name, entry) in &config.mcp_servers {
278        if let Some(ref cmd) = entry.command {
279            // Check if the command binary exists.
280            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            // Check if the SSE endpoint is reachable.
300            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    // 10. Disk space (warn if < 1GB free).
323    // Simple check via df.
324    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        // Parse the "Available" column from df output.
332        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        // Should always return at least a few checks.
382        assert!(checks.len() >= 3);
383
384        // Should always check for git.
385        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        // Should have a provider:env check for AZURE_OPENAI_API_KEY.
461        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        // Should have a check for the MCP server.
482        let mcp_check = checks.iter().find(|c| c.name == "mcp:test-server");
483        assert!(mcp_check.is_some());
484        // The binary won't exist, so it should fail.
485        assert_eq!(mcp_check.unwrap().status, CheckStatus::Fail);
486    }
487}