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 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(Parameter::builder("staged").param_type(ParameterType::Boolean).required(false)
64 .description("Show staged changes only (--cached). Default: false (shows unstaged)").build())
65 .parameter(Parameter::builder("file").param_type(ParameterType::String).required(false)
66 .description("Specific file to diff (optional)").build())
67 .parameter(Parameter::builder("base").param_type(ParameterType::String).required(false)
68 .description("Base commit/branch to diff against (e.g., 'main', 'HEAD~3')").build())
69 .parameter(Parameter::builder("stat").param_type(ParameterType::Boolean).required(false)
70 .description("Show diffstat summary instead of full diff").build())
71 .build()
72 }
73
74 async fn execute(&self, args: Value) -> crate::Result<Value> {
75 let staged = args["staged"].as_bool().unwrap_or(false);
76 let file = args["file"].as_str();
77 let base = args["base"].as_str();
78 let stat = args["stat"].as_bool().unwrap_or(false);
79
80 let mut git_args = vec!["diff"];
81
82 if staged {
83 git_args.push("--cached");
84 }
85
86 if stat {
87 git_args.push("--stat");
88 }
89
90 if let Some(b) = base {
91 git_args.push(b);
92 }
93
94 if let Some(f) = file {
95 git_args.push("--");
96 git_args.push(f);
97 }
98
99 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
100
101 if !success {
102 return Err(crate::PawanError::Git(format!(
103 "git diff failed: {}",
104 stderr
105 )));
106 }
107
108 let max_size = 100_000;
110 let truncated = stdout.len() > max_size;
111 let diff = if truncated {
112 format!(
113 "{}...\n[truncated, {} bytes total]",
114 &stdout[..max_size],
115 stdout.len()
116 )
117 } else {
118 stdout
119 };
120
121 Ok(json!({
122 "diff": diff,
123 "truncated": truncated,
124 "has_changes": !diff.trim().is_empty(),
125 "success": true
126 }))
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use serde_json::json;
134 use tempfile::TempDir;
135 use tokio::process::Command;
136
137 async fn setup_git_repo() -> TempDir {
138 let temp_dir = TempDir::new().unwrap();
139
140 Command::new("git")
141 .args(["init"])
142 .current_dir(temp_dir.path())
143 .output()
144 .await
145 .unwrap();
146
147 Command::new("git")
148 .args(["config", "user.email", "test@test.com"])
149 .current_dir(temp_dir.path())
150 .output()
151 .await
152 .unwrap();
153
154 Command::new("git")
155 .args(["config", "user.name", "Test User"])
156 .current_dir(temp_dir.path())
157 .output()
158 .await
159 .unwrap();
160
161 temp_dir
162 }
163
164 #[tokio::test]
165 async fn test_git_diff_no_changes() {
166 let temp_dir = setup_git_repo().await;
167
168 let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
169 let result = tool.execute(json!({})).await.unwrap();
170
171 assert!(result["success"].as_bool().unwrap());
172 assert!(!result["has_changes"].as_bool().unwrap());
173 }
174
175 #[tokio::test]
176 async fn test_git_diff_schema() {
177 let temp_dir = setup_git_repo().await;
178 let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
179 let schema = tool.parameters_schema();
180 let obj = schema.as_object().unwrap();
181 let props = obj.get("properties").unwrap().as_object().unwrap();
182 assert!(props.contains_key("staged"));
183 assert!(props.contains_key("file"));
184 assert!(props.contains_key("base"));
185 assert!(props.contains_key("stat"));
186 }
187
188 #[tokio::test]
189 async fn test_git_diff_with_changes() {
190 let temp_dir = setup_git_repo().await;
191 std::fs::write(temp_dir.path().join("f.txt"), "original").unwrap();
193 Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
194 Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
195 std::fs::write(temp_dir.path().join("f.txt"), "modified").unwrap();
197
198 let tool = GitDiffTool::new(temp_dir.path().into());
199 let result = tool.execute(json!({})).await.unwrap();
200 assert!(result["success"].as_bool().unwrap());
201 assert!(result["has_changes"].as_bool().unwrap());
202 assert!(result["diff"].as_str().unwrap().contains("modified"));
203 }
204}