1use anyhow::Result;
4use rig::completion::ToolDefinition;
5use rig::tool::Tool;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::path::Path;
10use std::process::Stdio;
11use std::time::Duration;
12use tokio::process::Command;
13use tokio::time::timeout;
14
15use super::common::{current_repo_root, parameters_schema};
16
17crate::define_tool_error!(StaticAnalysisError);
18
19const DEFAULT_TIMEOUT_SECS: u64 = 300;
20const DEFAULT_MAX_OUTPUT_CHARS: usize = 12_000;
21const MIN_OUTPUT_CHARS: usize = 512;
22const MAX_TIMEOUT_SECS: u64 = 600;
23const MAX_OUTPUT_CHARS: usize = 40_000;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct StaticAnalysis;
27
28#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30pub enum StaticAnalyzer {
31 #[default]
32 Auto,
33 Rust,
34 Python,
35 Javascript,
36 Go,
37}
38
39impl fmt::Display for StaticAnalyzer {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 match self {
42 Self::Auto => write!(f, "auto"),
43 Self::Rust => write!(f, "rust"),
44 Self::Python => write!(f, "python"),
45 Self::Javascript => write!(f, "javascript"),
46 Self::Go => write!(f, "go"),
47 }
48 }
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
52pub struct StaticAnalysisArgs {
53 #[serde(default)]
54 pub analyzer: StaticAnalyzer,
55 #[serde(default = "default_timeout_secs")]
56 #[schemars(
57 description = "Seconds to wait per analysis command. Values are clamped to 1..600."
58 )]
59 pub timeout_secs: u64,
60 #[serde(default = "default_max_output_chars")]
61 #[schemars(
62 description = "Maximum characters to return per command. Values are clamped to 512..40000."
63 )]
64 pub max_output_chars: usize,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub(super) struct AnalysisCommand {
69 pub(super) name: &'static str,
70 pub(super) executable: &'static str,
71 pub(super) args: Vec<&'static str>,
72 pub(super) reason: &'static str,
73}
74
75impl Default for StaticAnalysis {
76 fn default() -> Self {
77 Self
78 }
79}
80
81fn default_timeout_secs() -> u64 {
82 DEFAULT_TIMEOUT_SECS
83}
84
85fn default_max_output_chars() -> usize {
86 DEFAULT_MAX_OUTPUT_CHARS
87}
88
89impl Tool for StaticAnalysis {
90 const NAME: &'static str = "static_analysis";
91 type Error = StaticAnalysisError;
92 type Args = StaticAnalysisArgs;
93 type Output = String;
94
95 async fn definition(&self, _: String) -> ToolDefinition {
96 ToolDefinition {
97 name: Self::NAME.to_string(),
98 description: "Run installed static analysis tools directly without performing package install steps. Supports Rust/clippy, Python/ruff, JavaScript or TypeScript/biome or oxlint, and Go/golangci-lint or go vet. Use this during review to prioritize analyzer findings and avoid reporting issues a linter already catches. These tools can execute project build scripts, plugins, or analyzer configuration, so only run them in trusted workspaces. Timeouts clamp to 1..=600 seconds; output truncates to 512..=40000 characters.".to_string(),
99 parameters: parameters_schema::<StaticAnalysisArgs>(),
100 }
101 }
102
103 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
104 let repo_root = current_repo_root()?;
105 let commands = select_analysis_commands(&repo_root, args.analyzer, command_available);
106 if commands.is_empty() {
107 let availability =
108 unavailable_analysis_summary(&repo_root, args.analyzer, command_available);
109 return Ok(format!(
110 "No installed static analysis command found for `{}`. Supported direct commands: cargo, ruff, biome, oxlint, golangci-lint, go.\n{}",
111 args.analyzer,
112 availability.join("\n")
113 ));
114 }
115
116 let timeout_secs = args.timeout_secs.clamp(1, MAX_TIMEOUT_SECS);
117 let max_output_chars = args
118 .max_output_chars
119 .clamp(MIN_OUTPUT_CHARS, MAX_OUTPUT_CHARS);
120 let mut output = format!(
121 "Static analysis: {} command(s), timeout {}s each\n",
122 commands.len(),
123 timeout_secs
124 );
125
126 for command in commands {
127 output.push_str(&format!(
128 "\n## {}\nReason: {}\nCommand: {} {}\n",
129 command.name,
130 command.reason,
131 command.executable,
132 command.args.join(" ")
133 ));
134 output.push_str(
135 &run_analysis_command(&repo_root, &command, timeout_secs, max_output_chars).await,
136 );
137 output.push('\n');
138 }
139
140 Ok(output)
141 }
142}
143
144pub(super) fn select_analysis_commands(
145 repo_root: &Path,
146 analyzer: StaticAnalyzer,
147 command_available: impl Fn(&str) -> bool,
148) -> Vec<AnalysisCommand> {
149 let mut commands = Vec::new();
150 let wants = |candidate| analyzer == StaticAnalyzer::Auto || analyzer == candidate;
151
152 if wants(StaticAnalyzer::Rust)
153 && (analyzer == StaticAnalyzer::Rust || repo_root.join("Cargo.toml").is_file())
154 && command_available("cargo")
155 {
156 commands.push(AnalysisCommand {
157 name: "Rust clippy",
158 executable: "cargo",
159 args: vec![
160 "clippy",
161 "--workspace",
162 "--no-deps",
163 "--message-format",
164 "short",
165 ],
166 reason: if analyzer == StaticAnalyzer::Rust {
167 "Rust analyzer requested"
168 } else {
169 "Cargo.toml detected"
170 },
171 });
172 }
173
174 if wants(StaticAnalyzer::Python)
175 && (analyzer == StaticAnalyzer::Python
176 || has_any(
177 repo_root,
178 &["pyproject.toml", "ruff.toml", ".ruff.toml", "setup.cfg"],
179 ))
180 && command_available("ruff")
181 {
182 commands.push(AnalysisCommand {
183 name: "Python ruff",
184 executable: "ruff",
185 args: vec!["check", "."],
186 reason: if analyzer == StaticAnalyzer::Python {
187 "Python analyzer requested"
188 } else {
189 "Python project config detected"
190 },
191 });
192 }
193
194 if wants(StaticAnalyzer::Javascript)
195 && (analyzer == StaticAnalyzer::Javascript || repo_root.join("package.json").is_file())
196 {
197 if command_available("biome") {
198 commands.push(AnalysisCommand {
199 name: "JavaScript/TypeScript biome",
200 executable: "biome",
201 args: vec!["check", "."],
202 reason: if analyzer == StaticAnalyzer::Javascript {
203 "JavaScript analyzer requested and biome is installed"
204 } else {
205 "package.json detected and biome is installed"
206 },
207 });
208 } else if command_available("oxlint") {
209 commands.push(AnalysisCommand {
210 name: "JavaScript/TypeScript oxlint",
211 executable: "oxlint",
212 args: vec!["."],
213 reason: if analyzer == StaticAnalyzer::Javascript {
214 "JavaScript analyzer requested and oxlint is installed"
215 } else {
216 "package.json detected and oxlint is installed"
217 },
218 });
219 }
220 }
221
222 if wants(StaticAnalyzer::Go)
223 && (analyzer == StaticAnalyzer::Go || repo_root.join("go.mod").is_file())
224 {
225 if command_available("golangci-lint") {
226 commands.push(AnalysisCommand {
227 name: "Go golangci-lint",
228 executable: "golangci-lint",
229 args: vec!["run"],
230 reason: if analyzer == StaticAnalyzer::Go {
231 "Go analyzer requested and golangci-lint is installed"
232 } else {
233 "go.mod detected and golangci-lint is installed"
234 },
235 });
236 } else if command_available("go") {
237 commands.push(AnalysisCommand {
238 name: "Go vet",
239 executable: "go",
240 args: vec!["vet", "./..."],
241 reason: if analyzer == StaticAnalyzer::Go {
242 "Go analyzer requested and go is installed"
243 } else {
244 "go.mod detected and go is installed"
245 },
246 });
247 }
248 }
249
250 commands
251}
252
253pub(super) fn unavailable_analysis_summary(
254 repo_root: &Path,
255 analyzer: StaticAnalyzer,
256 command_available: impl Fn(&str) -> bool,
257) -> Vec<String> {
258 let mut notes = Vec::new();
259 let wants = |candidate| analyzer == StaticAnalyzer::Auto || analyzer == candidate;
260 let python_config = has_any(
261 repo_root,
262 &["pyproject.toml", "ruff.toml", ".ruff.toml", "setup.cfg"],
263 );
264 let mut applicable_auto_marker = false;
265
266 if wants(StaticAnalyzer::Rust) {
267 let applicable = analyzer == StaticAnalyzer::Rust || repo_root.join("Cargo.toml").is_file();
268 applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
269 if applicable && !command_available("cargo") {
270 notes.push(if analyzer == StaticAnalyzer::Rust {
271 "Rust analyzer requested but cargo is not on PATH.".to_string()
272 } else {
273 "Cargo.toml detected but cargo is not on PATH.".to_string()
274 });
275 }
276 }
277
278 if wants(StaticAnalyzer::Python) {
279 let applicable = analyzer == StaticAnalyzer::Python || python_config;
280 applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
281 if applicable && !command_available("ruff") {
282 notes.push(if analyzer == StaticAnalyzer::Python {
283 "Python analyzer requested but ruff is not on PATH.".to_string()
284 } else {
285 "Python config detected but ruff is not on PATH.".to_string()
286 });
287 }
288 }
289
290 if wants(StaticAnalyzer::Javascript) {
291 let applicable =
292 analyzer == StaticAnalyzer::Javascript || repo_root.join("package.json").is_file();
293 applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
294 if applicable && !command_available("biome") && !command_available("oxlint") {
295 notes.push(if analyzer == StaticAnalyzer::Javascript {
296 "JavaScript analyzer requested but biome and oxlint are not on PATH.".to_string()
297 } else {
298 "package.json detected but biome and oxlint are not on PATH.".to_string()
299 });
300 }
301 }
302
303 if wants(StaticAnalyzer::Go) {
304 let applicable = analyzer == StaticAnalyzer::Go || repo_root.join("go.mod").is_file();
305 applicable_auto_marker |= analyzer == StaticAnalyzer::Auto && applicable;
306 if applicable && !command_available("golangci-lint") && !command_available("go") {
307 notes.push(if analyzer == StaticAnalyzer::Go {
308 "Go analyzer requested but golangci-lint and go are not on PATH.".to_string()
309 } else {
310 "go.mod detected but golangci-lint and go are not on PATH.".to_string()
311 });
312 }
313 }
314
315 if notes.is_empty() && analyzer == StaticAnalyzer::Auto && !applicable_auto_marker {
316 notes.push("No matching project markers detected for auto mode.".to_string());
317 }
318
319 notes
320}
321
322fn has_any(repo_root: &Path, names: &[&str]) -> bool {
323 names.iter().any(|name| repo_root.join(name).is_file())
324}
325
326fn command_available(command: &str) -> bool {
327 std::env::var_os("PATH").is_some_and(|paths| {
328 std::env::split_paths(&paths).any(|path| executable_exists(&path.join(command)))
329 })
330}
331
332pub(super) fn executable_exists(path: &Path) -> bool {
333 #[cfg(windows)]
334 {
335 if path.is_file() {
336 return true;
337 }
338
339 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
340 return false;
341 };
342 let pathext =
343 std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
344 pathext
345 .split(';')
346 .filter(|extension| !extension.is_empty())
347 .any(|extension| path.with_file_name(format!("{name}{extension}")).is_file())
348 }
349
350 #[cfg(unix)]
351 {
352 use std::os::unix::fs::PermissionsExt;
353
354 path.metadata()
355 .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
356 }
357
358 #[cfg(not(any(unix, windows)))]
359 {
360 path.is_file()
361 }
362}
363
364async fn run_analysis_command(
365 repo_root: &Path,
366 command: &AnalysisCommand,
367 timeout_secs: u64,
368 max_output_chars: usize,
369) -> String {
370 let mut process = Command::new(command.executable);
371 process.args(&command.args);
372 process.current_dir(repo_root);
373 process.stdin(Stdio::null());
374 process.stdout(Stdio::piped());
375 process.stderr(Stdio::piped());
376 process.kill_on_drop(true);
377
378 match timeout(Duration::from_secs(timeout_secs), process.output()).await {
379 Ok(Ok(output)) => format_command_output(output.status.success(), &output, max_output_chars),
380 Ok(Err(error)) => format!("Failed to run {}: {error}\n", command.executable),
381 Err(_) => format!("Timed out after {timeout_secs}s\n"),
382 }
383}
384
385fn format_command_output(success: bool, output: &std::process::Output, max_chars: usize) -> String {
386 let status = if success { "passed" } else { "failed" };
387 let stdout = String::from_utf8_lossy(&output.stdout);
388 let stderr = String::from_utf8_lossy(&output.stderr);
389 let combined = format!("Status: {status}\n\nstderr:\n{stderr}\n\nstdout:\n{stdout}");
390 truncate_chars(&combined, max_chars)
391}
392
393fn truncate_chars(text: &str, max_chars: usize) -> String {
394 let mut chars = text.chars();
395 let mut truncated = chars.by_ref().take(max_chars).collect::<String>();
396 if chars.next().is_none() {
397 return truncated;
398 }
399
400 truncated.push_str("\n[static_analysis output truncated]");
401 truncated
402}