Skip to main content

xcodeai/tools/
git_blame.rs

1// src/tools/git_blame.rs
2//
3// Git blame tool — shows which commit and author last modified each line of a
4// file, optionally restricted to a specific line range.
5//
6// For Rust learners: This is the simplest possible Tool implementation —
7// a single subprocess call with a small parameter set.  Notice how we handle
8// the optional line range by conditionally pushing "-L start,end" into the
9// argument list.  We never build a shell string; we pass Vec<String> directly
10// to Command::args() for safety.
11
12use 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        // 1. Require `path` parameter.
52        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        // 2. Optional line range: -L start,end
65        //    Both start_line and end_line must be provided for the range filter
66        //    to apply; if only one is given we ignore both to avoid bad syntax.
67        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 blame -L start,end
72            git_args.push(format!("-L{},{}", s, e));
73        }
74
75        // 3. The file path comes last (no "--" needed for blame since it takes
76        //    a single file argument, not a path list).
77        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                // Truncate to 50KB to avoid overwhelming the LLM context.
108                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        // git blame on a nonexistent file should error, not panic.
165        let _ = result;
166    }
167}