返回 CodeWhale
diagnostics.rs
根目录 / crates / tui / src / tools / diagnostics.rs
1 //! Workspace diagnostics tool: `diagnostics`.
2 //!
3 //! This tool gathers lightweight, best-effort environment information without
4 //! failing hard when optional commands are unavailable.
5
6 use std::env;
7 use std::path::Path;
8 use std::process::Command;
9
10 use async_trait::async_trait;
11 use serde::{Deserialize, Serialize};
12 use serde_json::{Value, json};
13
14 use super::spec::{
15 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
16 };
17
18 /// Tool for collecting workspace and toolchain diagnostics.
19 pub struct DiagnosticsTool;
20
21 #[derive(Debug, Clone, Serialize, Deserialize)]
22 struct DiagnosticsOutput {
23 workspace_root: String,
24 current_dir: Option<String>,
25 current_dir_error: Option<String>,
26 git_repo: bool,
27 git_branch: Option<String>,
28 git_error: Option<String>,
29 sandbox_available: bool,
30 sandbox_type: Option<String>,
31 bwrap_available: bool,
32 cgroup_version: Option<u8>,
33 rustc_version: Option<String>,
34 cargo_version: Option<String>,
35 /// User-trusted external paths the agent may access from this workspace
36 /// (`/trust add <path>` from the slash command, persisted in
37 /// `~/.deepseek/workspace-trust.json`). See issue #29.
38 #[serde(skip_serializing_if = "Vec::is_empty", default)]
39 trusted_external_paths: Vec<String>,
40 }
41
42 #[derive(Debug, Clone, Default)]
43 struct GitProbe {
44 detected: bool,
45 branch: Option<String>,
46 error: Option<String>,
47 }
48
49 #[async_trait]
50 impl ToolSpec for DiagnosticsTool {
51 fn name(&self) -> &'static str {
52 "diagnostics"
53 }
54
55 fn description(&self) -> &'static str {
56 "Report workspace info, git detection, sandbox availability, and Rust toolchain versions."
57 }
58
59 fn input_schema(&self) -> Value {
60 json!({
61 "type": "object",
62 "properties": {},
63 "required": [],
64 "additionalProperties": false
65 })
66 }
67
68 fn capabilities(&self) -> Vec<ToolCapability> {
69 vec![ToolCapability::ReadOnly]
70 }
71
72 fn approval_requirement(&self) -> ApprovalRequirement {
73 ApprovalRequirement::Auto
74 }
75
76 fn supports_parallel(&self) -> bool {
77 true
78 }
79
80 async fn execute(&self, _input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
81 let workspace_root = context.workspace.display().to_string();
82
83 let (current_dir, current_dir_error) = match env::current_dir() {
84 Ok(dir) => (Some(dir.display().to_string()), None),
85 Err(err) => (None, Some(err.to_string())),
86 };
87
88 let git = probe_git(&context.workspace);
89 let sandbox_type = match context.shell_manager.lock() {
90 Ok(manager) => manager.configured_sandbox_type().map(|s| s.to_string()),
91 Err(poisoned) => poisoned
92 .into_inner()
93 .configured_sandbox_type()
94 .map(|s| s.to_string()),
95 };
96 let sandbox_available = sandbox_type.is_some();
97
98 // Bubblewrap availability (#2184).
99 let bwrap_available = probe_bwrap_available();
100
101 // Cgroup version (Linux only).
102 let cgroup_version = probe_cgroup_version();
103
104 let trusted_external_paths = context
105 .trusted_external_paths
106 .iter()
107 .map(|p| p.display().to_string())
108 .collect();
109 let diagnostics = DiagnosticsOutput {
110 workspace_root,
111 current_dir,
112 current_dir_error,
113 git_repo: git.detected,
114 git_branch: git.branch,
115 git_error: git.error,
116 sandbox_available,
117 sandbox_type,
118 bwrap_available,
119 cgroup_version,
120 rustc_version: probe_version("rustc", &["--version"], &context.workspace),
121 cargo_version: probe_version("cargo", &["--version"], &context.workspace),
122 trusted_external_paths,
123 };
124
125 ToolResult::json(&diagnostics).map_err(|e| ToolError::execution_failed(e.to_string()))
126 }
127 }
128
129 // === Helpers ===
130
131 fn probe_git(workspace: &Path) -> GitProbe {
132 let rev_parse = run_command("git", &["rev-parse", "--is-inside-work-tree"], workspace);
133 match rev_parse {
134 CommandProbe::Success(out) => {
135 if out.trim() != "true" {
136 return GitProbe {
137 detected: false,
138 branch: None,
139 error: Some(format!("unexpected git rev-parse output: {out}")),
140 };
141 }
142 let branch = run_command("git", &["rev-parse", "--abbrev-ref", "HEAD"], workspace)
143 .into_success();
144 GitProbe {
145 detected: true,
146 branch,
147 error: None,
148 }
149 }
150 CommandProbe::Failed { stderr, .. } => GitProbe {
151 detected: false,
152 branch: None,
153 error: stderr,
154 },
155 CommandProbe::Missing => GitProbe {
156 detected: false,
157 branch: None,
158 error: Some("git is not installed or not in PATH".to_string()),
159 },
160 }
161 }
162
163 fn probe_bwrap_available() -> bool {
164 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
165 {
166 crate::sandbox::bwrap::is_available()
167 }
168 #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
169 {
170 false
171 }
172 }
173
174 fn probe_cgroup_version() -> Option<u8> {
175 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
176 {
177 let path = std::path::Path::new("/sys/fs/cgroup/cgroup.controllers");
178 if path.exists() {
179 return Some(2);
180 }
181 let path = std::path::Path::new("/sys/fs/cgroup");
182 if path.exists() {
183 return Some(1);
184 }
185 None
186 }
187 #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
188 {
189 None
190 }
191 }
192
193 fn probe_version(program: &str, args: &[&str], cwd: &Path) -> Option<String> {
194 run_command(program, args, cwd).into_success()
195 }
196
197 enum CommandProbe {
198 Success(String),
199 Failed { stderr: Option<String> },
200 Missing,
201 }
202
203 impl CommandProbe {
204 fn into_success(self) -> Option<String> {
205 match self {
206 CommandProbe::Success(out) => Some(out),
207 CommandProbe::Failed { .. } | CommandProbe::Missing => None,
208 }
209 }
210 }
211
212 fn run_command(program: &str, args: &[&str], cwd: &Path) -> CommandProbe {
213 let output = Command::new(program).args(args).current_dir(cwd).output();
214 let output = match output {
215 Ok(output) => output,
216 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return CommandProbe::Missing,
217 Err(_) => return CommandProbe::Failed { stderr: None },
218 };
219
220 if output.status.success() {
221 CommandProbe::Success(String::from_utf8_lossy(&output.stdout).trim().to_string())
222 } else {
223 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
224 CommandProbe::Failed {
225 stderr: if stderr.is_empty() {
226 None
227 } else {
228 Some(stderr)
229 },
230 }
231 }
232 }
233
234 #[cfg(test)]
235 mod tests {
236 use super::*;
237 use crate::dependencies::ExternalTool;
238 use std::fs;
239 use std::path::Path;
240 use tempfile::tempdir;
241
242 fn git_available() -> bool {
243 crate::dependencies::Git::available()
244 }
245
246 fn init_git_repo(root: &Path) {
247 let run = |args: &[&str]| {
248 let status = crate::dependencies::Git::status(args, root).expect("git should spawn");
249 assert!(status.success(), "git {args:?} failed");
250 };
251 run(&["init", "-q"]);
252 run(&["config", "core.autocrlf", "false"]);
253 run(&["config", "user.email", "test@example.com"]);
254 run(&["config", "user.name", "Test User"]);
255 fs::write(root.join("README.md"), "init\n").expect("write");
256 run(&["add", "."]);
257 run(&["commit", "-q", "-m", "init"]);
258 }
259
260 #[test]
261 fn diagnostics_schema_has_empty_required_array() {
262 let schema = DiagnosticsTool.input_schema();
263 assert_eq!(schema["properties"], json!({}));
264 assert_eq!(schema["required"], json!([]));
265 }
266
267 #[tokio::test]
268 async fn diagnostics_runs_best_effort_outside_git_repo() {
269 let tmp = tempdir().expect("tempdir");
270 let ctx = ToolContext::new(tmp.path());
271 let tool = DiagnosticsTool;
272 let result = tool.execute(json!({}), &ctx).await.expect("execute");
273 assert!(result.success);
274
275 let parsed: DiagnosticsOutput =
276 serde_json::from_str(&result.content).expect("tool result should be json");
277 assert_eq!(parsed.workspace_root, tmp.path().display().to_string());
278 let expected = ctx
279 .shell_manager
280 .lock()
281 .expect("shell manager")
282 .configured_sandbox_type()
283 .map(|kind| kind.to_string());
284 assert_eq!(parsed.sandbox_available, expected.is_some());
285 assert_eq!(parsed.sandbox_type, expected);
286 }
287
288 #[tokio::test]
289 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
290 async fn diagnostics_only_reports_configured_executable_bwrap_on_linux() {
291 let tmp = tempdir().expect("tempdir");
292 let ctx = ToolContext::new(tmp.path());
293 ctx.shell_manager
294 .lock()
295 .expect("shell manager")
296 .set_prefer_bwrap(true);
297
298 let result = DiagnosticsTool
299 .execute(json!({}), &ctx)
300 .await
301 .expect("execute");
302 let parsed: DiagnosticsOutput =
303 serde_json::from_str(&result.content).expect("tool result should be json");
304
305 assert_eq!(
306 parsed.bwrap_available,
307 crate::sandbox::bwrap::is_available()
308 );
309 assert_eq!(parsed.sandbox_available, parsed.bwrap_available);
310 assert_eq!(
311 parsed.sandbox_type.as_deref(),
312 parsed.bwrap_available.then_some("linux-bwrap")
313 );
314 }
315
316 #[tokio::test]
317 async fn diagnostics_detects_git_repo_when_available() {
318 if !git_available() {
319 return;
320 }
321 let tmp = tempdir().expect("tempdir");
322 init_git_repo(tmp.path());
323
324 let ctx = ToolContext::new(tmp.path());
325 let tool = DiagnosticsTool;
326 let result = tool.execute(json!({}), &ctx).await.expect("execute");
327 assert!(result.success);
328
329 let parsed: DiagnosticsOutput =
330 serde_json::from_str(&result.content).expect("tool result should be json");
331 assert!(parsed.git_repo);
332 assert!(!parsed.git_branch.as_deref().unwrap_or("").is_empty());
333 }
334 }
335
335 lines RUST