返回 DeepSeek-TUI-2026
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 rustc_version: Option<String>,
32 cargo_version: Option<String>,
33 /// User-trusted external paths the agent may access from this workspace
34 /// (`/trust add <path>` from the slash command, persisted in
35 /// `~/.deepseek/workspace-trust.json`). See issue #29.
36 #[serde(skip_serializing_if = "Vec::is_empty", default)]
37 trusted_external_paths: Vec<String>,
38 }
39
40 #[derive(Debug, Clone, Default)]
41 struct GitProbe {
42 detected: bool,
43 branch: Option<String>,
44 error: Option<String>,
45 }
46
47 #[async_trait]
48 impl ToolSpec for DiagnosticsTool {
49 fn name(&self) -> &'static str {
50 "diagnostics"
51 }
52
53 fn description(&self) -> &'static str {
54 "Report workspace info, git detection, sandbox availability, and Rust toolchain versions."
55 }
56
57 fn input_schema(&self) -> Value {
58 json!({
59 "type": "object",
60 "properties": {},
61 "additionalProperties": false
62 })
63 }
64
65 fn capabilities(&self) -> Vec<ToolCapability> {
66 vec![ToolCapability::ReadOnly]
67 }
68
69 fn approval_requirement(&self) -> ApprovalRequirement {
70 ApprovalRequirement::Auto
71 }
72
73 fn supports_parallel(&self) -> bool {
74 true
75 }
76
77 async fn execute(&self, _input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
78 let workspace_root = context.workspace.display().to_string();
79
80 let (current_dir, current_dir_error) = match env::current_dir() {
81 Ok(dir) => (Some(dir.display().to_string()), None),
82 Err(err) => (None, Some(err.to_string())),
83 };
84
85 let git = probe_git(&context.workspace);
86 let sandbox_type = crate::sandbox::get_platform_sandbox().map(|s| s.to_string());
87 let sandbox_available = sandbox_type.is_some();
88
89 let trusted_external_paths = context
90 .trusted_external_paths
91 .iter()
92 .map(|p| p.display().to_string())
93 .collect();
94 let diagnostics = DiagnosticsOutput {
95 workspace_root,
96 current_dir,
97 current_dir_error,
98 git_repo: git.detected,
99 git_branch: git.branch,
100 git_error: git.error,
101 sandbox_available,
102 sandbox_type,
103 rustc_version: probe_version("rustc", &["--version"], &context.workspace),
104 cargo_version: probe_version("cargo", &["--version"], &context.workspace),
105 trusted_external_paths,
106 };
107
108 ToolResult::json(&diagnostics).map_err(|e| ToolError::execution_failed(e.to_string()))
109 }
110 }
111
112 // === Helpers ===
113
114 fn probe_git(workspace: &Path) -> GitProbe {
115 let rev_parse = run_command("git", &["rev-parse", "--is-inside-work-tree"], workspace);
116 match rev_parse {
117 CommandProbe::Success(out) => {
118 if out.trim() != "true" {
119 return GitProbe {
120 detected: false,
121 branch: None,
122 error: Some(format!("unexpected git rev-parse output: {out}")),
123 };
124 }
125 let branch = run_command("git", &["rev-parse", "--abbrev-ref", "HEAD"], workspace)
126 .into_success();
127 GitProbe {
128 detected: true,
129 branch,
130 error: None,
131 }
132 }
133 CommandProbe::Failed { stderr, .. } => GitProbe {
134 detected: false,
135 branch: None,
136 error: stderr,
137 },
138 CommandProbe::Missing => GitProbe {
139 detected: false,
140 branch: None,
141 error: Some("git is not installed or not in PATH".to_string()),
142 },
143 }
144 }
145
146 fn probe_version(program: &str, args: &[&str], cwd: &Path) -> Option<String> {
147 run_command(program, args, cwd).into_success()
148 }
149
150 enum CommandProbe {
151 Success(String),
152 Failed { stderr: Option<String> },
153 Missing,
154 }
155
156 impl CommandProbe {
157 fn into_success(self) -> Option<String> {
158 match self {
159 CommandProbe::Success(out) => Some(out),
160 CommandProbe::Failed { .. } | CommandProbe::Missing => None,
161 }
162 }
163 }
164
165 fn run_command(program: &str, args: &[&str], cwd: &Path) -> CommandProbe {
166 let output = Command::new(program).args(args).current_dir(cwd).output();
167 let output = match output {
168 Ok(output) => output,
169 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return CommandProbe::Missing,
170 Err(_) => return CommandProbe::Failed { stderr: None },
171 };
172
173 if output.status.success() {
174 CommandProbe::Success(String::from_utf8_lossy(&output.stdout).trim().to_string())
175 } else {
176 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
177 CommandProbe::Failed {
178 stderr: if stderr.is_empty() {
179 None
180 } else {
181 Some(stderr)
182 },
183 }
184 }
185 }
186
187 #[cfg(test)]
188 mod tests {
189 use super::*;
190 use std::fs;
191 use std::path::Path;
192 use std::process::Command;
193 use tempfile::tempdir;
194
195 fn git_available() -> bool {
196 Command::new("git")
197 .arg("--version")
198 .output()
199 .map(|o| o.status.success())
200 .unwrap_or(false)
201 }
202
203 fn init_git_repo(root: &Path) {
204 let run = |args: &[&str]| {
205 let status = Command::new("git")
206 .args(args)
207 .current_dir(root)
208 .status()
209 .expect("git should spawn");
210 assert!(status.success(), "git {:?} failed", args);
211 };
212 run(&["init", "-q"]);
213 run(&["config", "user.email", "test@example.com"]);
214 run(&["config", "user.name", "Test User"]);
215 fs::write(root.join("README.md"), "init\n").expect("write");
216 run(&["add", "."]);
217 run(&["commit", "-q", "-m", "init"]);
218 }
219
220 #[tokio::test]
221 async fn diagnostics_runs_best_effort_outside_git_repo() {
222 let tmp = tempdir().expect("tempdir");
223 let ctx = ToolContext::new(tmp.path());
224 let tool = DiagnosticsTool;
225 let result = tool.execute(json!({}), &ctx).await.expect("execute");
226 assert!(result.success);
227
228 let parsed: DiagnosticsOutput =
229 serde_json::from_str(&result.content).expect("tool result should be json");
230 assert_eq!(parsed.workspace_root, tmp.path().display().to_string());
231 }
232
233 #[tokio::test]
234 async fn diagnostics_detects_git_repo_when_available() {
235 if !git_available() {
236 return;
237 }
238 let tmp = tempdir().expect("tempdir");
239 init_git_repo(tmp.path());
240
241 let ctx = ToolContext::new(tmp.path());
242 let tool = DiagnosticsTool;
243 let result = tool.execute(json!({}), &ctx).await.expect("execute");
244 assert!(result.success);
245
246 let parsed: DiagnosticsOutput =
247 serde_json::from_str(&result.content).expect("tool result should be json");
248 assert!(parsed.git_repo);
249 assert!(!parsed.git_branch.as_deref().unwrap_or("").is_empty());
250 }
251 }
252
252 lines RUST