xcodeai/tools/
git_blame.rs1use crate::tools::{Tool, ToolContext, ToolResult};
13use anyhow::Result;
14use async_trait::async_trait;
15
16pub struct GitBlameTool;
17
18#[async_trait]
19impl Tool for GitBlameTool {
20 fn name(&self) -> &str {
21 "git_blame"
22 }
23
24 fn description(&self) -> &str {
25 "Show which commit and author last modified each line of a file. \
26 Supports restricting output to a specific line range."
27 }
28
29 fn parameters_schema(&self) -> serde_json::Value {
30 serde_json::json!({
31 "type": "object",
32 "properties": {
33 "path": {
34 "type": "string",
35 "description": "Path to the file to blame (relative to project root or absolute)"
36 },
37 "start_line": {
38 "type": "integer",
39 "description": "First line of the range to blame (1-indexed, inclusive)"
40 },
41 "end_line": {
42 "type": "integer",
43 "description": "Last line of the range to blame (1-indexed, inclusive)"
44 }
45 },
46 "required": ["path"]
47 })
48 }
49
50 async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> Result<ToolResult> {
51 let path_str = match args["path"].as_str() {
53 Some(p) if !p.is_empty() => p.to_string(),
54 _ => {
55 return Ok(ToolResult {
56 output: "Missing required argument: path".to_string(),
57 is_error: true,
58 });
59 }
60 };
61
62 let mut git_args: Vec<String> = vec!["blame".to_string()];
63
64 let start = args["start_line"].as_u64();
68 let end = args["end_line"].as_u64();
69
70 if let (Some(s), Some(e)) = (start, end) {
71 git_args.push(format!("-L{},{}", s, e));
73 }
74
75 git_args.push(path_str.clone());
78
79 let output = std::process::Command::new("git")
80 .args(&git_args)
81 .current_dir(&ctx.working_dir)
82 .output();
83
84 match output {
85 Err(e) => Ok(ToolResult {
86 output: format!("Failed to run git: {}", e),
87 is_error: true,
88 }),
89 Ok(out) => {
90 if !out.status.success() {
91 let stderr = String::from_utf8_lossy(&out.stderr);
92 return Ok(ToolResult {
93 output: format!("git blame failed: {}", stderr.trim()),
94 is_error: true,
95 });
96 }
97
98 let blame_text = String::from_utf8_lossy(&out.stdout).trim().to_string();
99
100 if blame_text.is_empty() {
101 return Ok(ToolResult {
102 output: "No blame output (file may be empty or untracked).".to_string(),
103 is_error: false,
104 });
105 }
106
107 const MAX_BYTES: usize = 50 * 1024;
109 let output = if blame_text.len() > MAX_BYTES {
110 let mut p = MAX_BYTES;
111 while p > 0 && !blame_text.is_char_boundary(p) {
112 p -= 1;
113 }
114 format!(
115 "{}\n\n[... output truncated — use start_line/end_line to narrow the range ...]",
116 &blame_text[..p]
117 )
118 } else {
119 blame_text
120 };
121
122 Ok(ToolResult {
123 output,
124 is_error: false,
125 })
126 }
127 }
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use std::path::PathBuf;
135
136 fn ctx() -> ToolContext {
137 ToolContext {
138 working_dir: PathBuf::from("/tmp"),
139 sandbox_enabled: false,
140 io: std::sync::Arc::new(crate::io::NullIO),
141 compact_mode: false,
142 lsp_client: std::sync::Arc::new(tokio::sync::Mutex::new(None)),
143 mcp_client: None,
144 nesting_depth: 0,
145 llm: std::sync::Arc::new(crate::llm::NullLlmProvider),
146 tools: std::sync::Arc::new(crate::tools::ToolRegistry::new()),
147 }
148 }
149
150 #[tokio::test]
151 async fn test_git_blame_missing_path() {
152 let tool = GitBlameTool;
153 let args = serde_json::json!({});
154 let result = tool.execute(args, &ctx()).await.unwrap();
155 assert!(result.is_error);
156 assert!(result.output.contains("Missing required argument: path"));
157 }
158
159 #[tokio::test]
160 async fn test_git_blame_nonexistent_file() {
161 let tool = GitBlameTool;
162 let args = serde_json::json!({ "path": "nonexistent_file_xcode_test.rs" });
163 let result = tool.execute(args, &ctx()).await.unwrap();
164 let _ = result;
166 }
167}