batuta/agent/tool/
pmat_query.rs1use async_trait::async_trait;
11use std::process::Command;
12
13use crate::agent::capability::Capability;
14use crate::agent::driver::ToolDefinition;
15
16use super::{Tool, ToolResult};
17
18const MAX_OUTPUT_BYTES: usize = 32_768;
20
21#[derive(Default)]
28pub struct PmatQueryTool;
29
30impl PmatQueryTool {
31 pub fn new() -> Self {
32 Self
33 }
34}
35
36#[async_trait]
37impl Tool for PmatQueryTool {
38 fn name(&self) -> &'static str {
39 "pmat_query"
40 }
41
42 fn definition(&self) -> ToolDefinition {
43 ToolDefinition {
44 name: "pmat_query".into(),
45 description:
46 "Search code by intent with quality annotations. Returns functions ranked \
47 by relevance with TDG grade, complexity, and call graph. Preferred over \
48 grep for code discovery."
49 .into(),
50 input_schema: serde_json::json!({
51 "type": "object",
52 "required": ["query"],
53 "properties": {
54 "query": {
55 "type": "string",
56 "description": "Semantic search query (e.g., 'error handling', 'cache invalidation')"
57 },
58 "limit": {
59 "type": "integer",
60 "description": "Max results (default: 10)"
61 },
62 "include_source": {
63 "type": "boolean",
64 "description": "Include function source code in results"
65 },
66 "min_grade": {
67 "type": "string",
68 "description": "Minimum TDG grade filter (A, B, C, D, F)"
69 },
70 "max_complexity": {
71 "type": "integer",
72 "description": "Maximum cyclomatic complexity filter"
73 },
74 "exclude_tests": {
75 "type": "boolean",
76 "description": "Exclude test functions from results"
77 },
78 "faults": {
79 "type": "boolean",
80 "description": "Show fault patterns (unwrap, panic, unsafe)"
81 },
82 "regex": {
83 "type": "string",
84 "description": "Regex pattern match instead of semantic search"
85 },
86 "literal": {
87 "type": "string",
88 "description": "Exact literal string match instead of semantic search"
89 }
90 }
91 }),
92 }
93 }
94
95 async fn execute(&self, input: serde_json::Value) -> ToolResult {
96 let query = input.get("query").and_then(|v| v.as_str()).unwrap_or("");
97 let regex = input.get("regex").and_then(|v| v.as_str());
98 let literal = input.get("literal").and_then(|v| v.as_str());
99
100 let mut cmd = Command::new("pmat");
102 cmd.arg("query");
103
104 if let Some(re) = regex {
106 cmd.args(["--regex", re]);
107 } else if let Some(lit) = literal {
108 cmd.args(["--literal", lit]);
109 } else if !query.is_empty() {
110 cmd.arg(query);
111 } else {
112 return ToolResult::error("provide 'query', 'regex', or 'literal'");
113 }
114
115 let limit = input.get("limit").and_then(|v| v.as_u64()).unwrap_or(10);
117 cmd.args(["--limit", &limit.to_string()]);
118
119 if input.get("include_source").and_then(|v| v.as_bool()).unwrap_or(false) {
120 cmd.arg("--include-source");
121 }
122
123 if let Some(grade) = input.get("min_grade").and_then(|v| v.as_str()) {
124 cmd.args(["--min-grade", grade]);
125 }
126
127 if let Some(complexity) = input.get("max_complexity").and_then(|v| v.as_u64()) {
128 cmd.args(["--max-complexity", &complexity.to_string()]);
129 }
130
131 if input.get("exclude_tests").and_then(|v| v.as_bool()).unwrap_or(false) {
132 cmd.arg("--exclude-tests");
133 }
134
135 if input.get("faults").and_then(|v| v.as_bool()).unwrap_or(false) {
136 cmd.arg("--faults");
137 }
138
139 let output = match cmd.output() {
141 Ok(o) => o,
142 Err(e) => {
143 return ToolResult::error(format!(
144 "pmat not found on PATH: {e}. Install: cargo install pmat"
145 ));
146 }
147 };
148
149 let stdout = String::from_utf8_lossy(&output.stdout);
150 let stderr = String::from_utf8_lossy(&output.stderr);
151
152 if !output.status.success() {
153 return ToolResult::error(format!("pmat query failed: {stderr}"));
154 }
155
156 let mut result = stdout.to_string();
158 if result.len() > MAX_OUTPUT_BYTES {
159 result.truncate(MAX_OUTPUT_BYTES);
160 result.push_str("\n[truncated — use --limit or narrower query]");
161 }
162
163 if result.trim().is_empty() {
164 ToolResult::success("No results found.")
165 } else {
166 ToolResult::success(result)
167 }
168 }
169
170 fn required_capability(&self) -> Capability {
171 Capability::FileRead { allowed_paths: vec!["*".into()] }
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn test_tool_name() {
181 let tool = PmatQueryTool::new();
182 assert_eq!(tool.name(), "pmat_query");
183 }
184
185 #[test]
186 fn test_definition_has_query_field() {
187 let tool = PmatQueryTool::new();
188 let def = tool.definition();
189 assert_eq!(def.name, "pmat_query");
190 let schema = &def.input_schema;
191 let required = schema["required"].as_array().unwrap();
192 assert!(required.iter().any(|v| v.as_str() == Some("query")));
193 }
194
195 #[test]
196 fn test_required_capability() {
197 let tool = PmatQueryTool::new();
198 assert!(matches!(tool.required_capability(), Capability::FileRead { .. }));
199 }
200
201 #[tokio::test]
202 async fn test_empty_query_errors() {
203 let tool = PmatQueryTool::new();
204 let result = tool.execute(serde_json::json!({"query": ""})).await;
205 assert!(result.is_error);
206 }
207
208 #[tokio::test]
209 async fn test_semantic_query_runs() {
210 let tool = PmatQueryTool::new();
211 let result = tool
212 .execute(serde_json::json!({
213 "query": "error handling",
214 "limit": 3
215 }))
216 .await;
217 if !result.is_error {
219 assert!(!result.content.is_empty());
220 }
221 }
222
223 #[tokio::test]
224 async fn test_regex_query_runs() {
225 let tool = PmatQueryTool::new();
226 let result = tool
227 .execute(serde_json::json!({
228 "query": "",
229 "regex": "fn\\s+test_",
230 "limit": 3
231 }))
232 .await;
233 if !result.is_error {
234 assert!(!result.content.is_empty());
235 }
236 }
237
238 #[tokio::test]
239 async fn test_literal_query_runs() {
240 let tool = PmatQueryTool::new();
241 let result = tool
242 .execute(serde_json::json!({
243 "query": "",
244 "literal": "unwrap()",
245 "limit": 3,
246 "exclude_tests": true
247 }))
248 .await;
249 if !result.is_error {
250 assert!(!result.content.is_empty());
251 }
252 }
253}