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::v2::{CodeMarker, Decision, Narrative};
11
12#[derive(Debug, Default)]
14pub struct CollectedOutput {
15 pub narrative: Option<Narrative>,
16 pub decisions: Vec<Decision>,
17 pub markers: Vec<CodeMarker>,
18}
19
20pub fn tool_definitions() -> Vec<ToolDefinition> {
22 vec![
23 ToolDefinition {
24 name: "get_diff".to_string(),
25 description: "Get the full unified diff for this commit.".to_string(),
26 input_schema: serde_json::json!({
27 "type": "object",
28 "properties": {},
29 "required": []
30 }),
31 },
32 ToolDefinition {
33 name: "get_file_content".to_string(),
34 description: "Get the content of a file at this commit.".to_string(),
35 input_schema: serde_json::json!({
36 "type": "object",
37 "properties": {
38 "path": {
39 "type": "string",
40 "description": "Path of the file to read"
41 }
42 },
43 "required": ["path"]
44 }),
45 },
46 ToolDefinition {
47 name: "get_commit_info".to_string(),
48 description: "Get commit metadata: SHA, message, author, timestamp.".to_string(),
49 input_schema: serde_json::json!({
50 "type": "object",
51 "properties": {},
52 "required": []
53 }),
54 },
55 ToolDefinition {
56 name: "emit_narrative".to_string(),
57 description: "Emit the commit-level narrative (REQUIRED, call exactly once). \
58 Tell the story of this commit: what it does, why this approach, \
59 what was considered and rejected."
60 .to_string(),
61 input_schema: serde_json::json!({
62 "type": "object",
63 "properties": {
64 "summary": {
65 "type": "string",
66 "description": "What this commit does and WHY this approach. Not a diff restatement."
67 },
68 "motivation": {
69 "type": "string",
70 "description": "What triggered this change? User request, bug, planned work?"
71 },
72 "rejected_alternatives": {
73 "type": "array",
74 "description": "Approaches that were considered and rejected",
75 "items": {
76 "type": "object",
77 "properties": {
78 "approach": { "type": "string" },
79 "reason": { "type": "string" }
80 },
81 "required": ["approach", "reason"]
82 }
83 },
84 "follow_up": {
85 "type": "string",
86 "description": "Expected follow-up work, if any. Omit if this is complete."
87 }
88 },
89 "required": ["summary"]
90 }),
91 },
92 ToolDefinition {
93 name: "emit_decision".to_string(),
94 description: "Emit a design or architectural decision made in this commit.".to_string(),
95 input_schema: serde_json::json!({
96 "type": "object",
97 "properties": {
98 "what": { "type": "string", "description": "What was decided" },
99 "why": { "type": "string", "description": "Why this decision was made" },
100 "stability": {
101 "type": "string",
102 "enum": ["permanent", "provisional", "experimental"],
103 "description": "How stable is this decision?"
104 },
105 "revisit_when": {
106 "type": "string",
107 "description": "When should this decision be reconsidered?"
108 },
109 "scope": {
110 "type": "array",
111 "items": { "type": "string" },
112 "description": "Files/modules this decision applies to"
113 }
114 },
115 "required": ["what", "why", "stability"]
116 }),
117 },
118 ToolDefinition {
119 name: "emit_marker".to_string(),
120 description: "Emit a code marker for genuinely non-obvious behavior. \
121 Only use for contracts, hazards, dependencies, or unstable code. \
122 Do NOT emit a marker for every function."
123 .to_string(),
124 input_schema: serde_json::json!({
125 "type": "object",
126 "properties": {
127 "file": { "type": "string", "description": "File path" },
128 "anchor": {
129 "type": "object",
130 "description": "Optional AST anchor. Omit for file-level markers.",
131 "properties": {
132 "unit_type": { "type": "string" },
133 "name": { "type": "string" },
134 "signature": { "type": "string" }
135 },
136 "required": ["unit_type", "name"]
137 },
138 "lines": {
139 "type": "object",
140 "properties": {
141 "start": { "type": "integer" },
142 "end": { "type": "integer" }
143 },
144 "required": ["start", "end"]
145 },
146 "kind": {
147 "type": "string",
148 "enum": ["contract", "hazard", "dependency", "unstable"],
149 "description": "Type of marker"
150 },
151 "description": {
152 "type": "string",
153 "description": "For contract/hazard/unstable: what the behavior or concern is"
154 },
155 "source": {
156 "type": "string",
157 "enum": ["author", "inferred"],
158 "description": "For contracts: whether the author stated this or it was inferred"
159 },
160 "target_file": { "type": "string", "description": "For dependency: the file depended on" },
161 "target_anchor": { "type": "string", "description": "For dependency: the anchor depended on" },
162 "assumption": { "type": "string", "description": "For dependency: what is assumed" },
163 "revisit_when": { "type": "string", "description": "For unstable: when to revisit" }
164 },
165 "required": ["file", "kind"]
166 }),
167 },
168 ]
169}
170
171pub fn dispatch_tool(
173 name: &str,
174 input: &serde_json::Value,
175 git_ops: &dyn GitOps,
176 context: &AnnotationContext,
177 collected: &mut CollectedOutput,
178) -> Result<String, AgentError> {
179 match name {
180 "get_diff" => dispatch_get_diff(context),
181 "get_file_content" => dispatch_get_file_content(input, git_ops, context),
182 "get_commit_info" => dispatch_get_commit_info(context),
183 "emit_narrative" => dispatch_emit_narrative(input, collected),
184 "emit_decision" => dispatch_emit_decision(input, collected),
185 "emit_marker" => dispatch_emit_marker(input, collected),
186 _ => Ok(format!("Unknown tool: {name}")),
187 }
188}
189
190fn dispatch_get_diff(context: &AnnotationContext) -> Result<String, AgentError> {
191 let mut out = String::new();
192 for diff in &context.diffs {
193 out.push_str(&format!(
194 "--- a/{}\n+++ b/{}\n",
195 diff.old_path.as_deref().unwrap_or(&diff.path),
196 &diff.path
197 ));
198 for hunk in &diff.hunks {
199 out.push_str(&hunk.header);
200 out.push('\n');
201 for line in &hunk.lines {
202 match line {
203 HunkLine::Context(s) => {
204 out.push(' ');
205 out.push_str(s);
206 out.push('\n');
207 }
208 HunkLine::Added(s) => {
209 out.push('+');
210 out.push_str(s);
211 out.push('\n');
212 }
213 HunkLine::Removed(s) => {
214 out.push('-');
215 out.push_str(s);
216 out.push('\n');
217 }
218 }
219 }
220 }
221 }
222 Ok(out)
223}
224
225fn dispatch_get_file_content(
226 input: &serde_json::Value,
227 git_ops: &dyn GitOps,
228 context: &AnnotationContext,
229) -> Result<String, AgentError> {
230 let path = input.get("path").and_then(|v| v.as_str()).ok_or_else(|| {
231 AgentError::InvalidAnnotation {
232 message: "get_file_content requires 'path' parameter".to_string(),
233 location: snafu::Location::default(),
234 }
235 })?;
236 let content = git_ops
237 .file_at_commit(Path::new(path), &context.commit_sha)
238 .context(GitSnafu)?;
239 Ok(content)
240}
241
242fn dispatch_get_commit_info(context: &AnnotationContext) -> Result<String, AgentError> {
243 Ok(format!(
244 "SHA: {}\nMessage: {}\nAuthor: {} <{}>\nTimestamp: {}",
245 context.commit_sha,
246 context.commit_message,
247 context.author_name,
248 context.author_email,
249 context.timestamp,
250 ))
251}
252
253fn dispatch_emit_narrative(
254 input: &serde_json::Value,
255 collected: &mut CollectedOutput,
256) -> Result<String, AgentError> {
257 let narrative: Narrative = serde_json::from_value(input.clone()).context(JsonSnafu)?;
258 collected.narrative = Some(narrative);
259 Ok("Narrative emitted.".to_string())
260}
261
262fn dispatch_emit_decision(
263 input: &serde_json::Value,
264 collected: &mut CollectedOutput,
265) -> Result<String, AgentError> {
266 let decision: Decision = serde_json::from_value(input.clone()).context(JsonSnafu)?;
267 collected.decisions.push(decision);
268 Ok(format!(
269 "Decision emitted. Total decisions: {}",
270 collected.decisions.len()
271 ))
272}
273
274fn dispatch_emit_marker(
275 input: &serde_json::Value,
276 collected: &mut CollectedOutput,
277) -> Result<String, AgentError> {
278 use crate::schema::common::{AstAnchor, LineRange};
282 use crate::schema::v2::{ContractSource, MarkerKind};
283
284 let file = input
285 .get("file")
286 .and_then(|v| v.as_str())
287 .unwrap_or("")
288 .to_string();
289 let kind_str = input
290 .get("kind")
291 .and_then(|v| v.as_str())
292 .unwrap_or("hazard");
293
294 let anchor = input.get("anchor").and_then(|v| {
295 let unit_type = v.get("unit_type")?.as_str()?.to_string();
296 let name = v.get("name")?.as_str()?.to_string();
297 let signature = v
298 .get("signature")
299 .and_then(|s| s.as_str())
300 .map(String::from);
301 Some(AstAnchor {
302 unit_type,
303 name,
304 signature,
305 })
306 });
307
308 let lines = input.get("lines").and_then(|v| {
309 let start = v.get("start")?.as_u64()? as u32;
310 let end = v.get("end")?.as_u64()? as u32;
311 Some(LineRange { start, end })
312 });
313
314 let marker_kind = match kind_str {
315 "contract" => {
316 let description = input
317 .get("description")
318 .and_then(|v| v.as_str())
319 .unwrap_or("")
320 .to_string();
321 let source = match input.get("source").and_then(|v| v.as_str()) {
322 Some("author") => ContractSource::Author,
323 _ => ContractSource::Inferred,
324 };
325 MarkerKind::Contract {
326 description,
327 source,
328 }
329 }
330 "hazard" => {
331 let description = input
332 .get("description")
333 .and_then(|v| v.as_str())
334 .unwrap_or("")
335 .to_string();
336 MarkerKind::Hazard { description }
337 }
338 "dependency" => {
339 let target_file = input
340 .get("target_file")
341 .and_then(|v| v.as_str())
342 .unwrap_or("")
343 .to_string();
344 let target_anchor = input
345 .get("target_anchor")
346 .and_then(|v| v.as_str())
347 .unwrap_or("")
348 .to_string();
349 let assumption = input
350 .get("assumption")
351 .and_then(|v| v.as_str())
352 .unwrap_or("")
353 .to_string();
354 MarkerKind::Dependency {
355 target_file,
356 target_anchor,
357 assumption,
358 }
359 }
360 "unstable" => {
361 let description = input
362 .get("description")
363 .and_then(|v| v.as_str())
364 .unwrap_or("")
365 .to_string();
366 let revisit_when = input
367 .get("revisit_when")
368 .and_then(|v| v.as_str())
369 .unwrap_or("")
370 .to_string();
371 MarkerKind::Unstable {
372 description,
373 revisit_when,
374 }
375 }
376 _ => {
377 return Err(AgentError::InvalidAnnotation {
378 message: format!("Unknown marker kind: {kind_str}"),
379 location: snafu::Location::default(),
380 });
381 }
382 };
383
384 let marker = CodeMarker {
385 file,
386 anchor,
387 lines,
388 kind: marker_kind,
389 };
390 collected.markers.push(marker);
391 Ok(format!(
392 "Marker emitted. Total markers: {}",
393 collected.markers.len()
394 ))
395}