1use super::super::Tool;
2use super::run_git;
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(
60 Parameter::builder("count")
61 .param_type(ParameterType::Integer)
62 .required(false)
63 .description("Number of commits to show (default: 10)")
64 .build(),
65 )
66 .parameter(
67 Parameter::builder("file")
68 .param_type(ParameterType::String)
69 .required(false)
70 .description("Show commits for a specific file")
71 .build(),
72 )
73 .parameter(
74 Parameter::builder("oneline")
75 .param_type(ParameterType::Boolean)
76 .required(false)
77 .description("Use compact one-line format (default: false)")
78 .build(),
79 )
80 .build()
81 }
82
83 async fn execute(&self, args: Value) -> crate::Result<Value> {
84 let count = args["count"].as_u64().unwrap_or(10);
85 let file = args["file"].as_str();
86 let oneline = args["oneline"].as_bool().unwrap_or(false);
87
88 let count_str = count.to_string();
89 let mut git_args = vec!["log", "-n", &count_str];
90
91 if oneline {
92 git_args.push("--oneline");
93 } else {
94 git_args.extend_from_slice(&["--pretty=format:%h %an %ar %s"]);
95 }
96
97 if let Some(f) = file {
98 git_args.push("--");
99 git_args.push(f);
100 }
101
102 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
103
104 if !success {
105 return Err(crate::PawanError::Git(format!(
106 "git log failed: {}",
107 stderr
108 )));
109 }
110
111 let commit_count = stdout.lines().count();
112
113 Ok(json!({
114 "log": stdout.trim(),
115 "commit_count": commit_count,
116 "success": true
117 }))
118 }
119}
120
121pub struct GitBlameTool {
129 workspace_root: PathBuf,
130}
131
132impl GitBlameTool {
133 pub fn new(workspace_root: PathBuf) -> Self {
134 Self { workspace_root }
135 }
136}
137
138#[async_trait]
139impl Tool for GitBlameTool {
140 fn name(&self) -> &str {
141 "git_blame"
142 }
143
144 fn description(&self) -> &str {
145 "Show line-by-line authorship of a file. Useful for understanding who changed what."
146 }
147
148 fn parameters_schema(&self) -> Value {
149 json!({
150 "type": "object",
151 "properties": {
152 "file": {
153 "type": "string",
154 "description": "File to blame (required)"
155 },
156 "lines": {
157 "type": "string",
158 "description": "Line range, e.g., '10,20' for lines 10-20"
159 }
160 },
161 "required": ["file"]
162 })
163 }
164
165 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
166 use thulp_core::{Parameter, ParameterType};
167 thulp_core::ToolDefinition::builder("git_blame")
168 .description(self.description())
169 .parameter(
170 Parameter::builder("file")
171 .param_type(ParameterType::String)
172 .required(true)
173 .description("File to blame (required)")
174 .build(),
175 )
176 .parameter(
177 Parameter::builder("lines")
178 .param_type(ParameterType::String)
179 .required(false)
180 .description("Line range, e.g., '10,20' for lines 10-20")
181 .build(),
182 )
183 .build()
184 }
185
186 async fn execute(&self, args: Value) -> crate::Result<Value> {
187 let file = args["file"]
188 .as_str()
189 .ok_or_else(|| crate::PawanError::Tool("file is required for git_blame".into()))?;
190 let lines = args["lines"].as_str();
191
192 let mut git_args = vec!["blame", "--porcelain"];
193
194 let line_range;
195 if let Some(l) = lines {
196 line_range = format!("-L{}", l);
197 git_args.push(&line_range);
198 }
199
200 git_args.push(file);
201
202 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
203
204 if !success {
205 return Err(crate::PawanError::Git(format!(
206 "git blame failed: {}",
207 stderr
208 )));
209 }
210
211 let max_size = 50_000;
213 let output = if stdout.len() > max_size {
214 format!(
215 "{}...\n[truncated, {} bytes total]",
216 &stdout[..max_size],
217 stdout.len()
218 )
219 } else {
220 stdout
221 };
222
223 Ok(json!({
224 "blame": output.trim(),
225 "success": true
226 }))
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::tools::git::staging::{GitAddTool, GitCommitTool};
234 use serde_json::json;
235 use tempfile::TempDir;
236 use tokio::process::Command;
237
238 async fn setup_git_repo() -> TempDir {
239 let temp_dir = TempDir::new().unwrap();
240
241 Command::new("git")
242 .args(["init"])
243 .current_dir(temp_dir.path())
244 .output()
245 .await
246 .unwrap();
247
248 Command::new("git")
249 .args(["config", "user.email", "test@test.com"])
250 .current_dir(temp_dir.path())
251 .output()
252 .await
253 .unwrap();
254
255 Command::new("git")
256 .args(["config", "user.name", "Test User"])
257 .current_dir(temp_dir.path())
258 .output()
259 .await
260 .unwrap();
261
262 temp_dir
263 }
264
265 #[tokio::test]
266 async fn test_git_log_tool_exists() {
267 let temp_dir = setup_git_repo().await;
268 let tool = GitLogTool::new(temp_dir.path().to_path_buf());
269 assert_eq!(tool.name(), "git_log");
270 }
271
272 #[tokio::test]
273 async fn test_git_log_with_commits() {
274 let temp_dir = setup_git_repo().await;
275 std::fs::write(temp_dir.path().join("a.txt"), "a").unwrap();
276 Command::new("git")
277 .args(["add", "."])
278 .current_dir(temp_dir.path())
279 .output()
280 .await
281 .unwrap();
282 Command::new("git")
283 .args(["commit", "-m", "first commit"])
284 .current_dir(temp_dir.path())
285 .output()
286 .await
287 .unwrap();
288 std::fs::write(temp_dir.path().join("b.txt"), "b").unwrap();
289 Command::new("git")
290 .args(["add", "."])
291 .current_dir(temp_dir.path())
292 .output()
293 .await
294 .unwrap();
295 Command::new("git")
296 .args(["commit", "-m", "second commit"])
297 .current_dir(temp_dir.path())
298 .output()
299 .await
300 .unwrap();
301
302 let tool = GitLogTool::new(temp_dir.path().into());
303 let result = tool.execute(json!({"count": 5})).await.unwrap();
304 assert!(result["success"].as_bool().unwrap());
305 let log = result["log"].as_str().unwrap();
306 assert!(log.contains("first commit"));
307 assert!(log.contains("second commit"));
308 }
309
310 #[tokio::test]
311 async fn test_git_blame_requires_file() {
312 let temp_dir = setup_git_repo().await;
313 let tool = GitBlameTool::new(temp_dir.path().into());
314 let result = tool.execute(json!({})).await;
315 assert!(result.is_err(), "blame without file should error");
316 }
317
318 #[tokio::test]
319 async fn test_git_log_with_count_limit() {
320 let temp_dir = setup_git_repo().await;
321 for i in 1..=3 {
323 std::fs::write(
324 temp_dir.path().join(format!("file{i}.txt")),
325 format!("v{i}"),
326 )
327 .unwrap();
328 GitAddTool::new(temp_dir.path().to_path_buf())
329 .execute(json!({ "files": [format!("file{i}.txt")] }))
330 .await
331 .unwrap();
332 GitCommitTool::new(temp_dir.path().to_path_buf())
333 .execute(json!({ "message": format!("commit {i}") }))
334 .await
335 .unwrap();
336 }
337
338 let tool = GitLogTool::new(temp_dir.path().to_path_buf());
341 let result = tool.execute(json!({ "count": 2 })).await.unwrap();
342 assert!(result["success"].as_bool().unwrap());
343 assert_eq!(
344 result["commit_count"].as_u64().unwrap(),
345 2,
346 "count=2 should return exactly 2 commits, got: {}",
347 result["log"].as_str().unwrap_or("")
348 );
349 let log = result["log"].as_str().unwrap();
351 assert!(
352 log.contains("commit 3"),
353 "expected 'commit 3' in log, got: {}",
354 log
355 );
356 assert!(
357 log.contains("commit 2"),
358 "expected 'commit 2' in log, got: {}",
359 log
360 );
361 assert!(
362 !log.contains("commit 1"),
363 "'commit 1' should be excluded by count=2, got: {}",
364 log
365 );
366 }
367
368 #[tokio::test]
369 async fn test_git_log_count_zero_uses_default_or_errors() {
370 let temp_dir = setup_git_repo().await;
373 std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
374 Command::new("git")
375 .args(["add", "."])
376 .current_dir(temp_dir.path())
377 .output()
378 .await
379 .unwrap();
380 Command::new("git")
381 .args(["commit", "-m", "init"])
382 .current_dir(temp_dir.path())
383 .output()
384 .await
385 .unwrap();
386
387 let tool = GitLogTool::new(temp_dir.path().to_path_buf());
388 let result = tool.execute(json!({ "count": 0 })).await;
390 assert!(result.is_ok() || result.is_err(), "count=0 should not hang");
393 }
394}