1use super::run_git;
2use super::super::Tool;
3use async_trait::async_trait;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6
7pub struct GitLogTool {
15 workspace_root: PathBuf,
16}
17
18impl GitLogTool {
19 pub fn new(workspace_root: PathBuf) -> Self {
20 Self { workspace_root }
21 }
22}
23
24#[async_trait]
25impl Tool for GitLogTool {
26 fn name(&self) -> &str {
27 "git_log"
28 }
29
30 fn description(&self) -> &str {
31 "Show git commit history. Supports limiting count, filtering by file, and custom format."
32 }
33
34 fn parameters_schema(&self) -> Value {
35 json!({
36 "type": "object",
37 "properties": {
38 "count": {
39 "type": "integer",
40 "description": "Number of commits to show (default: 10)"
41 },
42 "file": {
43 "type": "string",
44 "description": "Show commits for a specific file"
45 },
46 "oneline": {
47 "type": "boolean",
48 "description": "Use compact one-line format (default: false)"
49 }
50 },
51 "required": []
52 })
53 }
54
55 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
56 use thulp_core::{Parameter, ParameterType};
57 thulp_core::ToolDefinition::builder("git_log")
58 .description(self.description())
59 .parameter(Parameter::builder("count").param_type(ParameterType::Integer).required(false)
60 .description("Number of commits to show (default: 10)").build())
61 .parameter(Parameter::builder("file").param_type(ParameterType::String).required(false)
62 .description("Show commits for a specific file").build())
63 .parameter(Parameter::builder("oneline").param_type(ParameterType::Boolean).required(false)
64 .description("Use compact one-line format (default: false)").build())
65 .build()
66 }
67
68 async fn execute(&self, args: Value) -> crate::Result<Value> {
69 let count = args["count"].as_u64().unwrap_or(10);
70 let file = args["file"].as_str();
71 let oneline = args["oneline"].as_bool().unwrap_or(false);
72
73 let count_str = count.to_string();
74 let mut git_args = vec!["log", "-n", &count_str];
75
76 if oneline {
77 git_args.push("--oneline");
78 } else {
79 git_args.extend_from_slice(&["--pretty=format:%h %an %ar %s"]);
80 }
81
82 if let Some(f) = file {
83 git_args.push("--");
84 git_args.push(f);
85 }
86
87 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
88
89 if !success {
90 return Err(crate::PawanError::Git(format!(
91 "git log failed: {}",
92 stderr
93 )));
94 }
95
96 let commit_count = stdout.lines().count();
97
98 Ok(json!({
99 "log": stdout.trim(),
100 "commit_count": commit_count,
101 "success": true
102 }))
103 }
104}
105
106pub struct GitBlameTool {
114 workspace_root: PathBuf,
115}
116
117impl GitBlameTool {
118 pub fn new(workspace_root: PathBuf) -> Self {
119 Self { workspace_root }
120 }
121}
122
123#[async_trait]
124impl Tool for GitBlameTool {
125 fn name(&self) -> &str {
126 "git_blame"
127 }
128
129 fn description(&self) -> &str {
130 "Show line-by-line authorship of a file. Useful for understanding who changed what."
131 }
132
133 fn parameters_schema(&self) -> Value {
134 json!({
135 "type": "object",
136 "properties": {
137 "file": {
138 "type": "string",
139 "description": "File to blame (required)"
140 },
141 "lines": {
142 "type": "string",
143 "description": "Line range, e.g., '10,20' for lines 10-20"
144 }
145 },
146 "required": ["file"]
147 })
148 }
149
150 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
151 use thulp_core::{Parameter, ParameterType};
152 thulp_core::ToolDefinition::builder("git_blame")
153 .description(self.description())
154 .parameter(Parameter::builder("file").param_type(ParameterType::String).required(true)
155 .description("File to blame (required)").build())
156 .parameter(Parameter::builder("lines").param_type(ParameterType::String).required(false)
157 .description("Line range, e.g., '10,20' for lines 10-20").build())
158 .build()
159 }
160
161 async fn execute(&self, args: Value) -> crate::Result<Value> {
162 let file = args["file"]
163 .as_str()
164 .ok_or_else(|| crate::PawanError::Tool("file is required for git_blame".into()))?;
165 let lines = args["lines"].as_str();
166
167 let mut git_args = vec!["blame", "--porcelain"];
168
169 let line_range;
170 if let Some(l) = lines {
171 line_range = format!("-L{}", l);
172 git_args.push(&line_range);
173 }
174
175 git_args.push(file);
176
177 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
178
179 if !success {
180 return Err(crate::PawanError::Git(format!(
181 "git blame failed: {}",
182 stderr
183 )));
184 }
185
186 let max_size = 50_000;
188 let output = if stdout.len() > max_size {
189 format!(
190 "{}...\n[truncated, {} bytes total]",
191 &stdout[..max_size],
192 stdout.len()
193 )
194 } else {
195 stdout
196 };
197
198 Ok(json!({
199 "blame": output.trim(),
200 "success": true
201 }))
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::tools::git::staging::{GitAddTool, GitCommitTool};
209 use serde_json::json;
210 use tempfile::TempDir;
211 use tokio::process::Command;
212
213 async fn setup_git_repo() -> TempDir {
214 let temp_dir = TempDir::new().unwrap();
215
216 Command::new("git")
217 .args(["init"])
218 .current_dir(temp_dir.path())
219 .output()
220 .await
221 .unwrap();
222
223 Command::new("git")
224 .args(["config", "user.email", "test@test.com"])
225 .current_dir(temp_dir.path())
226 .output()
227 .await
228 .unwrap();
229
230 Command::new("git")
231 .args(["config", "user.name", "Test User"])
232 .current_dir(temp_dir.path())
233 .output()
234 .await
235 .unwrap();
236
237 temp_dir
238 }
239
240 #[tokio::test]
241 async fn test_git_log_tool_exists() {
242 let temp_dir = setup_git_repo().await;
243 let tool = GitLogTool::new(temp_dir.path().to_path_buf());
244 assert_eq!(tool.name(), "git_log");
245 }
246
247 #[tokio::test]
248 async fn test_git_log_with_commits() {
249 let temp_dir = setup_git_repo().await;
250 std::fs::write(temp_dir.path().join("a.txt"), "a").unwrap();
251 Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
252 Command::new("git").args(["commit", "-m", "first commit"]).current_dir(temp_dir.path()).output().await.unwrap();
253 std::fs::write(temp_dir.path().join("b.txt"), "b").unwrap();
254 Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
255 Command::new("git").args(["commit", "-m", "second commit"]).current_dir(temp_dir.path()).output().await.unwrap();
256
257 let tool = GitLogTool::new(temp_dir.path().into());
258 let result = tool.execute(json!({"count": 5})).await.unwrap();
259 assert!(result["success"].as_bool().unwrap());
260 let log = result["log"].as_str().unwrap();
261 assert!(log.contains("first commit"));
262 assert!(log.contains("second commit"));
263 }
264
265 #[tokio::test]
266 async fn test_git_blame_requires_file() {
267 let temp_dir = setup_git_repo().await;
268 let tool = GitBlameTool::new(temp_dir.path().into());
269 let result = tool.execute(json!({})).await;
270 assert!(result.is_err(), "blame without file should error");
271 }
272
273 #[tokio::test]
274 async fn test_git_log_with_count_limit() {
275 let temp_dir = setup_git_repo().await;
276 for i in 1..=3 {
278 std::fs::write(
279 temp_dir.path().join(format!("file{i}.txt")),
280 format!("v{i}"),
281 )
282 .unwrap();
283 GitAddTool::new(temp_dir.path().to_path_buf())
284 .execute(json!({ "files": [format!("file{i}.txt")] }))
285 .await
286 .unwrap();
287 GitCommitTool::new(temp_dir.path().to_path_buf())
288 .execute(json!({ "message": format!("commit {i}") }))
289 .await
290 .unwrap();
291 }
292
293 let tool = GitLogTool::new(temp_dir.path().to_path_buf());
296 let result = tool.execute(json!({ "count": 2 })).await.unwrap();
297 assert!(result["success"].as_bool().unwrap());
298 assert_eq!(
299 result["commit_count"].as_u64().unwrap(),
300 2,
301 "count=2 should return exactly 2 commits, got: {}",
302 result["log"].as_str().unwrap_or("")
303 );
304 let log = result["log"].as_str().unwrap();
306 assert!(log.contains("commit 3"), "expected 'commit 3' in log, got: {}", log);
307 assert!(log.contains("commit 2"), "expected 'commit 2' in log, got: {}", log);
308 assert!(!log.contains("commit 1"), "'commit 1' should be excluded by count=2, got: {}", log);
309 }
310
311 #[tokio::test]
312 async fn test_git_log_count_zero_uses_default_or_errors() {
313 let temp_dir = setup_git_repo().await;
316 std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
317 Command::new("git")
318 .args(["add", "."])
319 .current_dir(temp_dir.path())
320 .output()
321 .await
322 .unwrap();
323 Command::new("git")
324 .args(["commit", "-m", "init"])
325 .current_dir(temp_dir.path())
326 .output()
327 .await
328 .unwrap();
329
330 let tool = GitLogTool::new(temp_dir.path().to_path_buf());
331 let result = tool.execute(json!({ "count": 0 })).await;
333 assert!(
336 result.is_ok() || result.is_err(),
337 "count=0 should not hang"
338 );
339 }
340}