Skip to main content

batuta/agent/tool/
pmat_query.rs

1//! Dedicated `pmat_query` tool for agent code discovery.
2//!
3//! Replaces the `shell: pmat query "..."` fallback with a structured tool
4//! that returns quality-annotated, ranked results. This is the agent's
5//! primary code search tool — it understands TDG grades, complexity,
6//! fault patterns, and call graphs.
7//!
8//! PMAT-163: Phase 4a — stack-native tool replaces shell fallback.
9
10use 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
18/// Maximum output bytes before truncation.
19const MAX_OUTPUT_BYTES: usize = 32_768;
20
21/// Dedicated pmat query tool for code discovery.
22///
23/// Executes `pmat query` as a subprocess and returns structured results
24/// including function name, file, line range, TDG grade, and complexity.
25/// Supports all pmat query flags: `--include-source`, `--faults`,
26/// `--min-grade`, `--max-complexity`, `--exclude-tests`, etc.
27#[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        // Build pmat query command
101        let mut cmd = Command::new("pmat");
102        cmd.arg("query");
103
104        // Mode: regex, literal, or semantic (default)
105        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        // Optional flags
116        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        // Execute
140        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        // Truncate to prevent context overflow (Jidoka)
157        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        // pmat should be on PATH in dev environment
218        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}