1use std::path::Path;
2
3use snafu::ResultExt;
4
5use crate::annotate::gather::AnnotationContext;
6use crate::error::agent_error::{GitSnafu, JsonSnafu};
7use crate::error::AgentError;
8use crate::git::{GitOps, HunkLine};
9use crate::provider::ToolDefinition;
10use crate::schema::{CrossCuttingConcern, RegionAnnotation};
11
12pub fn tool_definitions() -> Vec<ToolDefinition> {
14 vec![
15 ToolDefinition {
16 name: "get_diff".to_string(),
17 description: "Get the full unified diff for this commit.".to_string(),
18 input_schema: serde_json::json!({
19 "type": "object",
20 "properties": {},
21 "required": []
22 }),
23 },
24 ToolDefinition {
25 name: "get_file_content".to_string(),
26 description: "Get the content of a file at this commit.".to_string(),
27 input_schema: serde_json::json!({
28 "type": "object",
29 "properties": {
30 "path": {
31 "type": "string",
32 "description": "Path of the file to read"
33 }
34 },
35 "required": ["path"]
36 }),
37 },
38 ToolDefinition {
39 name: "get_ast_outline".to_string(),
40 description: "Get a tree-sitter AST outline of semantic units in a file.".to_string(),
41 input_schema: serde_json::json!({
42 "type": "object",
43 "properties": {
44 "path": {
45 "type": "string",
46 "description": "Path of the file to analyze"
47 }
48 },
49 "required": ["path"]
50 }),
51 },
52 ToolDefinition {
53 name: "get_commit_info".to_string(),
54 description: "Get commit metadata: SHA, message, author, timestamp.".to_string(),
55 input_schema: serde_json::json!({
56 "type": "object",
57 "properties": {},
58 "required": []
59 }),
60 },
61 ToolDefinition {
62 name: "emit_annotation".to_string(),
63 description: "Emit a region annotation for a changed semantic unit.".to_string(),
64 input_schema: serde_json::json!({
65 "type": "object",
66 "properties": {
67 "file": { "type": "string", "description": "File path" },
68 "ast_anchor": {
69 "type": "object",
70 "properties": {
71 "unit_type": { "type": "string" },
72 "name": { "type": "string" },
73 "signature": { "type": "string" }
74 },
75 "required": ["unit_type", "name"]
76 },
77 "lines": {
78 "type": "object",
79 "properties": {
80 "start": { "type": "integer" },
81 "end": { "type": "integer" }
82 },
83 "required": ["start", "end"]
84 },
85 "intent": { "type": "string", "description": "What this change does and why" },
86 "reasoning": { "type": "string" },
87 "constraints": {
88 "type": "array",
89 "items": {
90 "type": "object",
91 "properties": {
92 "text": { "type": "string" },
93 "source": { "type": "string", "enum": ["author", "inferred"] }
94 },
95 "required": ["text", "source"]
96 }
97 },
98 "semantic_dependencies": {
99 "type": "array",
100 "items": {
101 "type": "object",
102 "properties": {
103 "file": { "type": "string" },
104 "anchor": { "type": "string" },
105 "nature": { "type": "string" }
106 },
107 "required": ["file", "anchor", "nature"]
108 }
109 },
110 "tags": {
111 "type": "array",
112 "items": { "type": "string" }
113 },
114 "risk_notes": { "type": "string" }
115 },
116 "required": ["file", "ast_anchor", "lines", "intent"]
117 }),
118 },
119 ToolDefinition {
120 name: "emit_cross_cutting".to_string(),
121 description: "Emit a cross-cutting concern that spans multiple regions.".to_string(),
122 input_schema: serde_json::json!({
123 "type": "object",
124 "properties": {
125 "description": { "type": "string" },
126 "regions": {
127 "type": "array",
128 "items": {
129 "type": "object",
130 "properties": {
131 "file": { "type": "string" },
132 "anchor": { "type": "string" }
133 },
134 "required": ["file", "anchor"]
135 }
136 },
137 "tags": {
138 "type": "array",
139 "items": { "type": "string" }
140 }
141 },
142 "required": ["description", "regions"]
143 }),
144 },
145 ]
146}
147
148pub fn dispatch_tool(
150 name: &str,
151 input: &serde_json::Value,
152 git_ops: &dyn GitOps,
153 context: &AnnotationContext,
154 collected_regions: &mut Vec<RegionAnnotation>,
155 collected_cross_cutting: &mut Vec<CrossCuttingConcern>,
156) -> Result<String, AgentError> {
157 match name {
158 "get_diff" => dispatch_get_diff(context),
159 "get_file_content" => dispatch_get_file_content(input, git_ops, context),
160 "get_ast_outline" => dispatch_get_ast_outline(input, git_ops, context),
161 "get_commit_info" => dispatch_get_commit_info(context),
162 "emit_annotation" => dispatch_emit_annotation(input, collected_regions),
163 "emit_cross_cutting" => dispatch_emit_cross_cutting(input, collected_cross_cutting),
164 _ => Ok(format!("Unknown tool: {name}")),
165 }
166}
167
168fn dispatch_get_diff(context: &AnnotationContext) -> Result<String, AgentError> {
169 let mut out = String::new();
170 for diff in &context.diffs {
171 out.push_str(&format!(
172 "--- a/{}\n+++ b/{}\n",
173 diff.old_path.as_deref().unwrap_or(&diff.path),
174 &diff.path
175 ));
176 for hunk in &diff.hunks {
177 out.push_str(&hunk.header);
178 out.push('\n');
179 for line in &hunk.lines {
180 match line {
181 HunkLine::Context(s) => {
182 out.push(' ');
183 out.push_str(s);
184 out.push('\n');
185 }
186 HunkLine::Added(s) => {
187 out.push('+');
188 out.push_str(s);
189 out.push('\n');
190 }
191 HunkLine::Removed(s) => {
192 out.push('-');
193 out.push_str(s);
194 out.push('\n');
195 }
196 }
197 }
198 }
199 }
200 Ok(out)
201}
202
203fn dispatch_get_file_content(
204 input: &serde_json::Value,
205 git_ops: &dyn GitOps,
206 context: &AnnotationContext,
207) -> Result<String, AgentError> {
208 let path = input.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
209 AgentError::InvalidAnnotation {
210 message: "get_file_content requires 'path' parameter".to_string(),
211 location: snafu::Location::default(),
212 }
213 })?;
214 let content = git_ops
215 .file_at_commit(Path::new(path), &context.commit_sha)
216 .context(GitSnafu)?;
217 Ok(content)
218}
219
220fn dispatch_get_ast_outline(
221 input: &serde_json::Value,
222 git_ops: &dyn GitOps,
223 context: &AnnotationContext,
224) -> Result<String, AgentError> {
225 let path = input.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
226 AgentError::InvalidAnnotation {
227 message: "get_ast_outline requires 'path' parameter".to_string(),
228 location: snafu::Location::default(),
229 }
230 })?;
231
232 let source = git_ops
233 .file_at_commit(Path::new(path), &context.commit_sha)
234 .context(GitSnafu)?;
235
236 let language = crate::ast::Language::from_path(path);
237 match crate::ast::extract_outline(&source, language) {
238 Ok(entries) => {
239 let mut out = String::new();
240 for entry in &entries {
241 out.push_str(&format!(
242 "{} {} (lines {}-{})",
243 entry.kind.as_str(),
244 entry.name,
245 entry.lines.start,
246 entry.lines.end,
247 ));
248 if let Some(sig) = &entry.signature {
249 out.push_str(&format!(" sig: {sig}"));
250 }
251 out.push('\n');
252 }
253 Ok(out)
254 }
255 Err(e) => Ok(format!("AST outline not available: {e}")),
256 }
257}
258
259fn dispatch_get_commit_info(context: &AnnotationContext) -> Result<String, AgentError> {
260 Ok(format!(
261 "SHA: {}\nMessage: {}\nAuthor: {} <{}>\nTimestamp: {}",
262 context.commit_sha,
263 context.commit_message,
264 context.author_name,
265 context.author_email,
266 context.timestamp,
267 ))
268}
269
270fn dispatch_emit_annotation(
271 input: &serde_json::Value,
272 collected_regions: &mut Vec<RegionAnnotation>,
273) -> Result<String, AgentError> {
274 let annotation: RegionAnnotation = serde_json::from_value(input.clone()).context(JsonSnafu)?;
275 collected_regions.push(annotation);
276 Ok(format!(
277 "Annotation emitted. Total annotations: {}",
278 collected_regions.len()
279 ))
280}
281
282fn dispatch_emit_cross_cutting(
283 input: &serde_json::Value,
284 collected_cross_cutting: &mut Vec<CrossCuttingConcern>,
285) -> Result<String, AgentError> {
286 let concern: CrossCuttingConcern = serde_json::from_value(input.clone()).context(JsonSnafu)?;
287 collected_cross_cutting.push(concern);
288 Ok(format!(
289 "Cross-cutting concern emitted. Total: {}",
290 collected_cross_cutting.len()
291 ))
292}