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 GitDiffTool {
15 workspace_root: PathBuf,
16}
17
18impl GitDiffTool {
19 pub fn new(workspace_root: PathBuf) -> Self {
20 Self { workspace_root }
21 }
22}
23
24#[async_trait]
25impl Tool for GitDiffTool {
26 fn name(&self) -> &str {
27 "git_diff"
28 }
29
30 fn description(&self) -> &str {
31 "Show git diff for staged or unstaged changes. Can diff against a specific commit or branch."
32 }
33
34 fn parameters_schema(&self) -> Value {
35 json!({
36 "type": "object",
37 "properties": {
38 "staged": {
39 "type": "boolean",
40 "description": "Show staged changes only (--cached). Default: false (shows unstaged)"
41 },
42 "file": {
43 "type": "string",
44 "description": "Specific file to diff (optional)"
45 },
46 "base": {
47 "type": "string",
48 "description": "Base commit/branch to diff against (e.g., 'main', 'HEAD~3')"
49 },
50 "stat": {
51 "type": "boolean",
52 "description": "Show diffstat summary instead of full diff"
53 }
54 },
55 "required": []
56 })
57 }
58
59 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
60 use thulp_core::{Parameter, ParameterType};
61 thulp_core::ToolDefinition::builder("git_diff")
62 .description(self.description())
63 .parameter(
64 Parameter::builder("staged")
65 .param_type(ParameterType::Boolean)
66 .required(false)
67 .description(
68 "Show staged changes only (--cached). Default: false (shows unstaged)",
69 )
70 .build(),
71 )
72 .parameter(
73 Parameter::builder("file")
74 .param_type(ParameterType::String)
75 .required(false)
76 .description("Specific file to diff (optional)")
77 .build(),
78 )
79 .parameter(
80 Parameter::builder("base")
81 .param_type(ParameterType::String)
82 .required(false)
83 .description("Base commit/branch to diff against (e.g., 'main', 'HEAD~3')")
84 .build(),
85 )
86 .parameter(
87 Parameter::builder("stat")
88 .param_type(ParameterType::Boolean)
89 .required(false)
90 .description("Show diffstat summary instead of full diff")
91 .build(),
92 )
93 .build()
94 }
95
96 async fn execute(&self, args: Value) -> crate::Result<Value> {
97 let staged = args["staged"].as_bool().unwrap_or(false);
98 let file = args["file"].as_str();
99 let base = args["base"].as_str();
100 let stat = args["stat"].as_bool().unwrap_or(false);
101
102 let mut git_args = vec!["diff"];
103
104 if staged {
105 git_args.push("--cached");
106 }
107
108 if stat {
109 git_args.push("--stat");
110 }
111
112 if let Some(b) = base {
113 git_args.push(b);
114 }
115
116 if let Some(f) = file {
117 git_args.push("--");
118 git_args.push(f);
119 }
120
121 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
122
123 if !success {
124 return Err(crate::PawanError::Git(format!(
125 "git diff failed: {}",
126 stderr
127 )));
128 }
129
130 let max_size = 100_000;
132 let truncated = stdout.len() > max_size;
133 let diff = if truncated {
134 format!(
135 "{}...\n[truncated, {} bytes total]",
136 &stdout[..max_size],
137 stdout.len()
138 )
139 } else {
140 stdout
141 };
142
143 Ok(json!({
144 "diff": diff,
145 "truncated": truncated,
146 "has_changes": !diff.trim().is_empty(),
147 "success": true
148 }))
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use serde_json::json;
156 use tempfile::TempDir;
157 use tokio::process::Command;
158
159 async fn setup_git_repo() -> TempDir {
160 let temp_dir = TempDir::new().unwrap();
161
162 Command::new("git")
163 .args(["init"])
164 .current_dir(temp_dir.path())
165 .output()
166 .await
167 .unwrap();
168
169 Command::new("git")
170 .args(["config", "user.email", "test@test.com"])
171 .current_dir(temp_dir.path())
172 .output()
173 .await
174 .unwrap();
175
176 Command::new("git")
177 .args(["config", "user.name", "Test User"])
178 .current_dir(temp_dir.path())
179 .output()
180 .await
181 .unwrap();
182
183 temp_dir
184 }
185
186 #[tokio::test]
187 async fn test_git_diff_no_changes() {
188 let temp_dir = setup_git_repo().await;
189
190 let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
191 let result = tool.execute(json!({})).await.unwrap();
192
193 assert!(result["success"].as_bool().unwrap());
194 assert!(!result["has_changes"].as_bool().unwrap());
195 }
196
197 #[tokio::test]
198 async fn test_git_diff_schema() {
199 let temp_dir = setup_git_repo().await;
200 let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
201 let schema = tool.parameters_schema();
202 let obj = schema.as_object().unwrap();
203 let props = obj.get("properties").unwrap().as_object().unwrap();
204 assert!(props.contains_key("staged"));
205 assert!(props.contains_key("file"));
206 assert!(props.contains_key("base"));
207 assert!(props.contains_key("stat"));
208 }
209
210 #[tokio::test]
211 async fn test_git_diff_with_changes() {
212 let temp_dir = setup_git_repo().await;
213 std::fs::write(temp_dir.path().join("f.txt"), "original").unwrap();
215 Command::new("git")
216 .args(["add", "."])
217 .current_dir(temp_dir.path())
218 .output()
219 .await
220 .unwrap();
221 Command::new("git")
222 .args(["commit", "-m", "init"])
223 .current_dir(temp_dir.path())
224 .output()
225 .await
226 .unwrap();
227 std::fs::write(temp_dir.path().join("f.txt"), "modified").unwrap();
229
230 let tool = GitDiffTool::new(temp_dir.path().into());
231 let result = tool.execute(json!({})).await.unwrap();
232 assert!(result["success"].as_bool().unwrap());
233 assert!(result["has_changes"].as_bool().unwrap());
234 assert!(result["diff"].as_str().unwrap().contains("modified"));
235 }
236}