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. API connectivity test.
178    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    // 8. MCP server health check.
224    for (name, entry) in &config.mcp_servers {
225        if let Some(ref cmd) = entry.command {
226            // Check if the command binary exists.
227            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            // Check if the SSE endpoint is reachable.
247            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    // 9. Disk space (warn if < 1GB free).
270    // Simple check via df.
271    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        // Parse the "Available" column from df output.
279        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        // Should always return at least a few checks.
329        assert!(checks.len() >= 3);
330
331        // Should always check for git.
332        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        // Should have a check for the MCP server.
396        let mcp_check = checks.iter().find(|c| c.name == "mcp:test-server");
397        assert!(mcp_check.is_some());
398        // The binary won't exist, so it should fail.
399        assert_eq!(mcp_check.unwrap().status, CheckStatus::Fail);
400    }
401}