1use super::Tool;
6use async_trait::async_trait;
7use serde_json::{json, Value};
8use std::path::PathBuf;
9use std::process::Stdio;
10use tokio::io::AsyncReadExt;
11use tokio::process::Command;
12
13async fn run_git(workspace: &PathBuf, args: &[&str]) -> crate::Result<(bool, String, String)> {
15 let mut cmd = Command::new("git");
16 cmd.args(args)
17 .current_dir(workspace)
18 .stdout(Stdio::piped())
19 .stderr(Stdio::piped())
20 .stdin(Stdio::null());
21
22 let mut child = cmd.spawn().map_err(crate::PawanError::Io)?;
23
24 let mut stdout = String::new();
25 let mut stderr = String::new();
26
27 if let Some(mut handle) = child.stdout.take() {
28 handle.read_to_string(&mut stdout).await.ok();
29 }
30 if let Some(mut handle) = child.stderr.take() {
31 handle.read_to_string(&mut stderr).await.ok();
32 }
33
34 let status = child.wait().await.map_err(crate::PawanError::Io)?;
35 Ok((status.success(), stdout, stderr))
36}
37
38pub struct GitStatusTool {
46 workspace_root: PathBuf,
47}
48
49impl GitStatusTool {
50 pub fn new(workspace_root: PathBuf) -> Self {
51 Self { workspace_root }
52 }
53}
54
55#[async_trait]
56impl Tool for GitStatusTool {
57 fn name(&self) -> &str {
58 "git_status"
59 }
60
61 fn description(&self) -> &str {
62 "Get the current git status showing staged, unstaged, and untracked files."
63 }
64
65 fn parameters_schema(&self) -> Value {
66 json!({
67 "type": "object",
68 "properties": {
69 "short": {
70 "type": "boolean",
71 "description": "Use short format output (default: false)"
72 }
73 },
74 "required": []
75 })
76 }
77
78 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
79 use thulp_core::{Parameter, ParameterType};
80 thulp_core::ToolDefinition::builder("git_status")
81 .description(self.description())
82 .parameter(Parameter::builder("short").param_type(ParameterType::Boolean).required(false)
83 .description("Use short format output (default: false)").build())
84 .build()
85 }
86
87 async fn execute(&self, args: Value) -> crate::Result<Value> {
88 let short = args["short"].as_bool().unwrap_or(false);
89
90 let mut git_args = vec!["status"];
91 if short {
92 git_args.push("-s");
93 }
94
95 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
96
97 if !success {
98 return Err(crate::PawanError::Git(format!(
99 "git status failed: {}",
100 stderr
101 )));
102 }
103
104 let (_, branch_output, _) =
106 run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
107 let branch = branch_output.trim().to_string();
108
109 let (_, porcelain, _) = run_git(&self.workspace_root, &["status", "--porcelain"]).await?;
111 let is_clean = porcelain.trim().is_empty();
112
113 Ok(json!({
114 "status": stdout.trim(),
115 "branch": branch,
116 "is_clean": is_clean,
117 "success": true
118 }))
119 }
120}
121
122pub struct GitDiffTool {
130 workspace_root: PathBuf,
131}
132
133impl GitDiffTool {
134 pub fn new(workspace_root: PathBuf) -> Self {
135 Self { workspace_root }
136 }
137}
138
139#[async_trait]
140impl Tool for GitDiffTool {
141 fn name(&self) -> &str {
142 "git_diff"
143 }
144
145 fn description(&self) -> &str {
146 "Show git diff for staged or unstaged changes. Can diff against a specific commit or branch."
147 }
148
149 fn parameters_schema(&self) -> Value {
150 json!({
151 "type": "object",
152 "properties": {
153 "staged": {
154 "type": "boolean",
155 "description": "Show staged changes only (--cached). Default: false (shows unstaged)"
156 },
157 "file": {
158 "type": "string",
159 "description": "Specific file to diff (optional)"
160 },
161 "base": {
162 "type": "string",
163 "description": "Base commit/branch to diff against (e.g., 'main', 'HEAD~3')"
164 },
165 "stat": {
166 "type": "boolean",
167 "description": "Show diffstat summary instead of full diff"
168 }
169 },
170 "required": []
171 })
172 }
173
174 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
175 use thulp_core::{Parameter, ParameterType};
176 thulp_core::ToolDefinition::builder("git_diff")
177 .description(self.description())
178 .parameter(Parameter::builder("staged").param_type(ParameterType::Boolean).required(false)
179 .description("Show staged changes only (--cached). Default: false (shows unstaged)").build())
180 .parameter(Parameter::builder("file").param_type(ParameterType::String).required(false)
181 .description("Specific file to diff (optional)").build())
182 .parameter(Parameter::builder("base").param_type(ParameterType::String).required(false)
183 .description("Base commit/branch to diff against (e.g., 'main', 'HEAD~3')").build())
184 .parameter(Parameter::builder("stat").param_type(ParameterType::Boolean).required(false)
185 .description("Show diffstat summary instead of full diff").build())
186 .build()
187 }
188
189 async fn execute(&self, args: Value) -> crate::Result<Value> {
190 let staged = args["staged"].as_bool().unwrap_or(false);
191 let file = args["file"].as_str();
192 let base = args["base"].as_str();
193 let stat = args["stat"].as_bool().unwrap_or(false);
194
195 let mut git_args = vec!["diff"];
196
197 if staged {
198 git_args.push("--cached");
199 }
200
201 if stat {
202 git_args.push("--stat");
203 }
204
205 if let Some(b) = base {
206 git_args.push(b);
207 }
208
209 if let Some(f) = file {
210 git_args.push("--");
211 git_args.push(f);
212 }
213
214 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
215
216 if !success {
217 return Err(crate::PawanError::Git(format!(
218 "git diff failed: {}",
219 stderr
220 )));
221 }
222
223 let max_size = 100_000;
225 let truncated = stdout.len() > max_size;
226 let diff = if truncated {
227 format!(
228 "{}...\n[truncated, {} bytes total]",
229 &stdout[..max_size],
230 stdout.len()
231 )
232 } else {
233 stdout
234 };
235
236 Ok(json!({
237 "diff": diff,
238 "truncated": truncated,
239 "has_changes": !diff.trim().is_empty(),
240 "success": true
241 }))
242 }
243}
244
245pub struct GitAddTool {
252 workspace_root: PathBuf,
253}
254
255impl GitAddTool {
256 pub fn new(workspace_root: PathBuf) -> Self {
257 Self { workspace_root }
258 }
259}
260
261#[async_trait]
262impl Tool for GitAddTool {
263 fn name(&self) -> &str {
264 "git_add"
265 }
266
267 fn description(&self) -> &str {
268 "Stage files for commit. Can stage specific files or all changes."
269 }
270
271 fn parameters_schema(&self) -> Value {
272 json!({
273 "type": "object",
274 "properties": {
275 "files": {
276 "type": "array",
277 "items": {"type": "string"},
278 "description": "List of files to stage. Use [\".\"] to stage all changes."
279 },
280 "all": {
281 "type": "boolean",
282 "description": "Stage all changes including untracked files (-A)"
283 }
284 },
285 "required": []
286 })
287 }
288
289 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
290 use thulp_core::{Parameter, ParameterType};
291 thulp_core::ToolDefinition::builder("git_add")
292 .description(self.description())
293 .parameter(Parameter::builder("files").param_type(ParameterType::Array).required(false)
294 .description("List of files to stage. Use [\".\"] to stage all changes.").build())
295 .parameter(Parameter::builder("all").param_type(ParameterType::Boolean).required(false)
296 .description("Stage all changes including untracked files (-A)").build())
297 .build()
298 }
299
300 async fn execute(&self, args: Value) -> crate::Result<Value> {
301 let all = args["all"].as_bool().unwrap_or(false);
302 let files: Vec<&str> = args["files"]
303 .as_array()
304 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
305 .unwrap_or_default();
306
307 let mut git_args = vec!["add"];
308
309 if all {
310 git_args.push("-A");
311 } else if files.is_empty() {
312 return Err(crate::PawanError::Tool(
313 "Either 'files' or 'all: true' must be specified".into(),
314 ));
315 } else {
316 for f in &files {
317 git_args.push(f);
318 }
319 }
320
321 let (success, _, stderr) = run_git(&self.workspace_root, &git_args).await?;
322
323 if !success {
324 return Err(crate::PawanError::Git(format!(
325 "git add failed: {}",
326 stderr
327 )));
328 }
329
330 let (_, status_output, _) = run_git(&self.workspace_root, &["status", "-s"]).await?;
332 let staged_count = status_output
333 .lines()
334 .filter(|l| l.starts_with('A') || l.starts_with('M') || l.starts_with('D'))
335 .count();
336
337 Ok(json!({
338 "success": true,
339 "staged_count": staged_count,
340 "message": if all {
341 "Staged all changes".to_string()
342 } else {
343 format!("Staged {} file(s)", files.len())
344 }
345 }))
346 }
347}
348
349pub struct GitCommitTool {
356 workspace_root: PathBuf,
357}
358
359impl GitCommitTool {
360 pub fn new(workspace_root: PathBuf) -> Self {
361 Self { workspace_root }
362 }
363}
364
365#[async_trait]
366impl Tool for GitCommitTool {
367 fn name(&self) -> &str {
368 "git_commit"
369 }
370
371 fn description(&self) -> &str {
372 "Create a git commit with the staged changes. Requires a commit message."
373 }
374
375 fn parameters_schema(&self) -> Value {
376 json!({
377 "type": "object",
378 "properties": {
379 "message": {
380 "type": "string",
381 "description": "Commit message (required)"
382 },
383 "body": {
384 "type": "string",
385 "description": "Extended commit body (optional)"
386 }
387 },
388 "required": ["message"]
389 })
390 }
391
392 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
393 use thulp_core::{Parameter, ParameterType};
394 thulp_core::ToolDefinition::builder("git_commit")
395 .description(self.description())
396 .parameter(Parameter::builder("message").param_type(ParameterType::String).required(true)
397 .description("Commit message (required)").build())
398 .parameter(Parameter::builder("body").param_type(ParameterType::String).required(false)
399 .description("Extended commit body (optional)").build())
400 .build()
401 }
402
403 async fn execute(&self, args: Value) -> crate::Result<Value> {
404 let message = args["message"]
405 .as_str()
406 .ok_or_else(|| crate::PawanError::Tool("commit message is required".into()))?;
407
408 let body = args["body"].as_str();
409
410 let (_, staged, _) = run_git(&self.workspace_root, &["diff", "--cached", "--stat"]).await?;
412 if staged.trim().is_empty() {
413 return Err(crate::PawanError::Git(
414 "No staged changes to commit. Use git_add first.".into(),
415 ));
416 }
417
418 let full_message = if let Some(b) = body {
420 format!("{}\n\n{}", message, b)
421 } else {
422 message.to_string()
423 };
424
425 let (success, stdout, stderr) =
426 run_git(&self.workspace_root, &["commit", "-m", &full_message]).await?;
427
428 if !success {
429 return Err(crate::PawanError::Git(format!(
430 "git commit failed: {}",
431 stderr
432 )));
433 }
434
435 let (_, hash_output, _) =
437 run_git(&self.workspace_root, &["rev-parse", "--short", "HEAD"]).await?;
438 let commit_hash = hash_output.trim().to_string();
439
440 Ok(json!({
441 "success": true,
442 "commit_hash": commit_hash,
443 "message": message,
444 "output": stdout.trim()
445 }))
446 }
447}
448
449pub struct GitLogTool {
457 workspace_root: PathBuf,
458}
459
460impl GitLogTool {
461 pub fn new(workspace_root: PathBuf) -> Self {
462 Self { workspace_root }
463 }
464}
465
466#[async_trait]
467impl Tool for GitLogTool {
468 fn name(&self) -> &str {
469 "git_log"
470 }
471
472 fn description(&self) -> &str {
473 "Show git commit history. Supports limiting count, filtering by file, and custom format."
474 }
475
476 fn parameters_schema(&self) -> Value {
477 json!({
478 "type": "object",
479 "properties": {
480 "count": {
481 "type": "integer",
482 "description": "Number of commits to show (default: 10)"
483 },
484 "file": {
485 "type": "string",
486 "description": "Show commits for a specific file"
487 },
488 "oneline": {
489 "type": "boolean",
490 "description": "Use compact one-line format (default: false)"
491 }
492 },
493 "required": []
494 })
495 }
496
497 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
498 use thulp_core::{Parameter, ParameterType};
499 thulp_core::ToolDefinition::builder("git_log")
500 .description(self.description())
501 .parameter(Parameter::builder("count").param_type(ParameterType::Integer).required(false)
502 .description("Number of commits to show (default: 10)").build())
503 .parameter(Parameter::builder("file").param_type(ParameterType::String).required(false)
504 .description("Show commits for a specific file").build())
505 .parameter(Parameter::builder("oneline").param_type(ParameterType::Boolean).required(false)
506 .description("Use compact one-line format (default: false)").build())
507 .build()
508 }
509
510 async fn execute(&self, args: Value) -> crate::Result<Value> {
511 let count = args["count"].as_u64().unwrap_or(10);
512 let file = args["file"].as_str();
513 let oneline = args["oneline"].as_bool().unwrap_or(false);
514
515 let count_str = count.to_string();
516 let mut git_args = vec!["log", "-n", &count_str];
517
518 if oneline {
519 git_args.push("--oneline");
520 } else {
521 git_args.extend_from_slice(&["--pretty=format:%h %an %ar %s"]);
522 }
523
524 if let Some(f) = file {
525 git_args.push("--");
526 git_args.push(f);
527 }
528
529 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
530
531 if !success {
532 return Err(crate::PawanError::Git(format!(
533 "git log failed: {}",
534 stderr
535 )));
536 }
537
538 let commit_count = stdout.lines().count();
539
540 Ok(json!({
541 "log": stdout.trim(),
542 "commit_count": commit_count,
543 "success": true
544 }))
545 }
546}
547
548pub struct GitBlameTool {
556 workspace_root: PathBuf,
557}
558
559impl GitBlameTool {
560 pub fn new(workspace_root: PathBuf) -> Self {
561 Self { workspace_root }
562 }
563}
564
565#[async_trait]
566impl Tool for GitBlameTool {
567 fn name(&self) -> &str {
568 "git_blame"
569 }
570
571 fn description(&self) -> &str {
572 "Show line-by-line authorship of a file. Useful for understanding who changed what."
573 }
574
575 fn parameters_schema(&self) -> Value {
576 json!({
577 "type": "object",
578 "properties": {
579 "file": {
580 "type": "string",
581 "description": "File to blame (required)"
582 },
583 "lines": {
584 "type": "string",
585 "description": "Line range, e.g., '10,20' for lines 10-20"
586 }
587 },
588 "required": ["file"]
589 })
590 }
591
592 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
593 use thulp_core::{Parameter, ParameterType};
594 thulp_core::ToolDefinition::builder("git_blame")
595 .description(self.description())
596 .parameter(Parameter::builder("file").param_type(ParameterType::String).required(true)
597 .description("File to blame (required)").build())
598 .parameter(Parameter::builder("lines").param_type(ParameterType::String).required(false)
599 .description("Line range, e.g., '10,20' for lines 10-20").build())
600 .build()
601 }
602
603 async fn execute(&self, args: Value) -> crate::Result<Value> {
604 let file = args["file"]
605 .as_str()
606 .ok_or_else(|| crate::PawanError::Tool("file is required for git_blame".into()))?;
607 let lines = args["lines"].as_str();
608
609 let mut git_args = vec!["blame", "--porcelain"];
610
611 let line_range;
612 if let Some(l) = lines {
613 line_range = format!("-L{}", l);
614 git_args.push(&line_range);
615 }
616
617 git_args.push(file);
618
619 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
620
621 if !success {
622 return Err(crate::PawanError::Git(format!(
623 "git blame failed: {}",
624 stderr
625 )));
626 }
627
628 let max_size = 50_000;
630 let output = if stdout.len() > max_size {
631 format!(
632 "{}...\n[truncated, {} bytes total]",
633 &stdout[..max_size],
634 stdout.len()
635 )
636 } else {
637 stdout
638 };
639
640 Ok(json!({
641 "blame": output.trim(),
642 "success": true
643 }))
644 }
645}
646
647pub struct GitBranchTool {
649 workspace_root: PathBuf,
650}
651
652impl GitBranchTool {
653 pub fn new(workspace_root: PathBuf) -> Self {
654 Self { workspace_root }
655 }
656}
657
658#[async_trait]
659impl Tool for GitBranchTool {
660 fn name(&self) -> &str {
661 "git_branch"
662 }
663
664 fn description(&self) -> &str {
665 "List branches or get current branch name. Shows local and optionally remote branches."
666 }
667
668 fn parameters_schema(&self) -> Value {
669 json!({
670 "type": "object",
671 "properties": {
672 "all": {
673 "type": "boolean",
674 "description": "Show both local and remote branches (default: false)"
675 }
676 },
677 "required": []
678 })
679 }
680
681 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
682 use thulp_core::{Parameter, ParameterType};
683 thulp_core::ToolDefinition::builder("git_branch")
684 .description(self.description())
685 .parameter(Parameter::builder("all").param_type(ParameterType::Boolean).required(false)
686 .description("Show both local and remote branches (default: false)").build())
687 .build()
688 }
689
690 async fn execute(&self, args: Value) -> crate::Result<Value> {
691 let all = args["all"].as_bool().unwrap_or(false);
692
693 let (_, current, _) = run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
695 let current_branch = current.trim().to_string();
696
697 let mut git_args = vec!["branch", "--format=%(refname:short)"];
699 if all {
700 git_args.push("-a");
701 }
702
703 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
704
705 if !success {
706 return Err(crate::PawanError::Git(format!(
707 "git branch failed: {}",
708 stderr
709 )));
710 }
711
712 let branches: Vec<&str> = stdout
713 .lines()
714 .map(|l| l.trim())
715 .filter(|l| !l.is_empty())
716 .collect();
717
718 Ok(json!({
719 "current": current_branch,
720 "branches": branches,
721 "count": branches.len(),
722 "success": true
723 }))
724 }
725}
726
727pub struct GitCheckoutTool {
729 workspace_root: PathBuf,
730}
731
732impl GitCheckoutTool {
733 pub fn new(workspace_root: PathBuf) -> Self {
734 Self { workspace_root }
735 }
736}
737
738#[async_trait]
739impl Tool for GitCheckoutTool {
740 fn name(&self) -> &str {
741 "git_checkout"
742 }
743
744 fn description(&self) -> &str {
745 "Switch branches or restore working tree files. Can create new branches with create=true."
746 }
747
748 fn parameters_schema(&self) -> Value {
749 json!({
750 "type": "object",
751 "properties": {
752 "target": {
753 "type": "string",
754 "description": "Branch name, commit, or file path to checkout"
755 },
756 "create": {
757 "type": "boolean",
758 "description": "Create a new branch (git checkout -b)"
759 },
760 "files": {
761 "type": "array",
762 "items": { "type": "string" },
763 "description": "Specific files to restore (git checkout -- <files>)"
764 }
765 },
766 "required": ["target"]
767 })
768 }
769
770 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
771 use thulp_core::{Parameter, ParameterType};
772 thulp_core::ToolDefinition::builder("git_checkout")
773 .description(self.description())
774 .parameter(Parameter::builder("target").param_type(ParameterType::String).required(true)
775 .description("Branch name, commit, or file path to checkout").build())
776 .parameter(Parameter::builder("create").param_type(ParameterType::Boolean).required(false)
777 .description("Create a new branch (git checkout -b)").build())
778 .parameter(Parameter::builder("files").param_type(ParameterType::Array).required(false)
779 .description("Specific files to restore (git checkout -- <files>)").build())
780 .build()
781 }
782
783 async fn execute(&self, args: Value) -> crate::Result<Value> {
784 let target = args["target"]
785 .as_str()
786 .ok_or_else(|| crate::PawanError::Tool("target is required".into()))?;
787 let create = args["create"].as_bool().unwrap_or(false);
788 let files: Vec<&str> = args["files"]
789 .as_array()
790 .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
791 .unwrap_or_default();
792
793 let mut git_args: Vec<&str> = vec!["checkout"];
794
795 if create {
796 git_args.push("-b");
797 }
798
799 git_args.push(target);
800
801 if !files.is_empty() {
802 git_args.push("--");
803 git_args.extend(files.iter());
804 }
805
806 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
807
808 if !success {
809 return Err(crate::PawanError::Git(format!(
810 "git checkout failed: {}",
811 stderr
812 )));
813 }
814
815 Ok(json!({
816 "success": true,
817 "target": target,
818 "created": create,
819 "output": format!("{}{}", stdout, stderr).trim().to_string()
820 }))
821 }
822}
823
824pub struct GitStashTool {
826 workspace_root: PathBuf,
827}
828
829impl GitStashTool {
830 pub fn new(workspace_root: PathBuf) -> Self {
831 Self { workspace_root }
832 }
833}
834
835#[async_trait]
836impl Tool for GitStashTool {
837 fn name(&self) -> &str {
838 "git_stash"
839 }
840
841 fn description(&self) -> &str {
842 "Stash or restore uncommitted changes. Actions: push (default), pop, list, drop."
843 }
844
845 fn parameters_schema(&self) -> Value {
846 json!({
847 "type": "object",
848 "properties": {
849 "action": {
850 "type": "string",
851 "enum": ["push", "pop", "list", "drop", "show"],
852 "description": "Stash action (default: push)"
853 },
854 "message": {
855 "type": "string",
856 "description": "Message for stash push"
857 },
858 "index": {
859 "type": "integer",
860 "description": "Stash index for pop/drop/show (default: 0)"
861 }
862 },
863 "required": []
864 })
865 }
866
867 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
868 use thulp_core::{Parameter, ParameterType};
869 thulp_core::ToolDefinition::builder("git_stash")
870 .description(self.description())
871 .parameter(Parameter::builder("action").param_type(ParameterType::String).required(false)
872 .description("Stash action (default: push)").build())
873 .parameter(Parameter::builder("message").param_type(ParameterType::String).required(false)
874 .description("Message for stash push").build())
875 .parameter(Parameter::builder("index").param_type(ParameterType::Integer).required(false)
876 .description("Stash index for pop/drop/show (default: 0)").build())
877 .build()
878 }
879
880 async fn execute(&self, args: Value) -> crate::Result<Value> {
881 let action = args["action"].as_str().unwrap_or("push");
882 let message = args["message"].as_str();
883 let index = args["index"].as_u64().unwrap_or(0);
884
885 let git_args: Vec<String> = match action {
886 "push" => {
887 let mut a = vec!["stash".to_string(), "push".to_string()];
888 if let Some(msg) = message {
889 a.push("-m".to_string());
890 a.push(msg.to_string());
891 }
892 a
893 }
894 "pop" => vec!["stash".to_string(), "pop".to_string(), format!("stash@{{{}}}", index)],
895 "list" => vec!["stash".to_string(), "list".to_string()],
896 "drop" => vec!["stash".to_string(), "drop".to_string(), format!("stash@{{{}}}", index)],
897 "show" => vec!["stash".to_string(), "show".to_string(), "-p".to_string(), format!("stash@{{{}}}", index)],
898 _ => return Err(crate::PawanError::Tool(format!("Unknown stash action: {}", action))),
899 };
900
901 let git_args_ref: Vec<&str> = git_args.iter().map(|s| s.as_str()).collect();
902 let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args_ref).await?;
903
904 if !success {
905 return Err(crate::PawanError::Git(format!(
906 "git stash {} failed: {}",
907 action, stderr
908 )));
909 }
910
911 Ok(json!({
912 "success": true,
913 "action": action,
914 "output": stdout.trim().to_string()
915 }))
916 }
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922 use tempfile::TempDir;
923
924 async fn setup_git_repo() -> TempDir {
925 let temp_dir = TempDir::new().unwrap();
926
927 let mut cmd = Command::new("git");
929 cmd.args(["init"])
930 .current_dir(temp_dir.path())
931 .output()
932 .await
933 .unwrap();
934
935 let mut cmd = Command::new("git");
937 cmd.args(["config", "user.email", "test@test.com"])
938 .current_dir(temp_dir.path())
939 .output()
940 .await
941 .unwrap();
942
943 let mut cmd = Command::new("git");
944 cmd.args(["config", "user.name", "Test User"])
945 .current_dir(temp_dir.path())
946 .output()
947 .await
948 .unwrap();
949
950 temp_dir
951 }
952
953 #[tokio::test]
954 async fn test_git_status_empty_repo() {
955 let temp_dir = setup_git_repo().await;
956
957 let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
958 let result = tool.execute(json!({})).await.unwrap();
959
960 assert!(result["success"].as_bool().unwrap());
961 assert!(result["is_clean"].as_bool().unwrap());
962 }
963
964 #[tokio::test]
965 async fn test_git_status_with_untracked() {
966 let temp_dir = setup_git_repo().await;
967
968 std::fs::write(temp_dir.path().join("test.txt"), "hello").unwrap();
970
971 let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
972 let result = tool.execute(json!({})).await.unwrap();
973
974 assert!(result["success"].as_bool().unwrap());
975 assert!(!result["is_clean"].as_bool().unwrap());
976 }
977
978 #[tokio::test]
979 async fn test_git_add_and_commit() {
980 let temp_dir = setup_git_repo().await;
981
982 std::fs::write(temp_dir.path().join("test.txt"), "hello").unwrap();
984
985 let add_tool = GitAddTool::new(temp_dir.path().to_path_buf());
987 let add_result = add_tool
988 .execute(json!({
989 "files": ["test.txt"]
990 }))
991 .await
992 .unwrap();
993 assert!(add_result["success"].as_bool().unwrap());
994
995 let commit_tool = GitCommitTool::new(temp_dir.path().to_path_buf());
997 let commit_result = commit_tool
998 .execute(json!({
999 "message": "Add test file"
1000 }))
1001 .await
1002 .unwrap();
1003 assert!(commit_result["success"].as_bool().unwrap());
1004 assert!(!commit_result["commit_hash"].as_str().unwrap().is_empty());
1005 }
1006
1007 #[tokio::test]
1008 async fn test_git_diff_no_changes() {
1009 let temp_dir = setup_git_repo().await;
1010
1011 let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
1012 let result = tool.execute(json!({})).await.unwrap();
1013
1014 assert!(result["success"].as_bool().unwrap());
1015 assert!(!result["has_changes"].as_bool().unwrap());
1016 }
1017 #[tokio::test]
1018 async fn test_git_status_tool_exists() {
1019 let temp_dir = setup_git_repo().await;
1020 let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
1021 assert_eq!(tool.name(), "git_status");
1022 }
1023
1024 #[tokio::test]
1025 async fn test_git_log_tool_exists() {
1026 let temp_dir = setup_git_repo().await;
1027 let tool = GitLogTool::new(temp_dir.path().to_path_buf());
1028 assert_eq!(tool.name(), "git_log");
1029 }
1030
1031 #[tokio::test]
1032 async fn test_git_diff_schema() {
1033 let temp_dir = setup_git_repo().await;
1034 let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
1035 let schema = tool.parameters_schema();
1036 let obj = schema.as_object().unwrap();
1037 let props = obj.get("properties").unwrap().as_object().unwrap();
1038 assert!(props.contains_key("staged"));
1039 assert!(props.contains_key("file"));
1040 assert!(props.contains_key("base"));
1041 assert!(props.contains_key("stat"));
1042 }
1043
1044 #[tokio::test]
1045 async fn test_git_diff_with_changes() {
1046 let temp_dir = setup_git_repo().await;
1047 std::fs::write(temp_dir.path().join("f.txt"), "original").unwrap();
1049 Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1050 Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
1051 std::fs::write(temp_dir.path().join("f.txt"), "modified").unwrap();
1053
1054 let tool = GitDiffTool::new(temp_dir.path().into());
1055 let result = tool.execute(json!({})).await.unwrap();
1056 assert!(result["success"].as_bool().unwrap());
1057 assert!(result["has_changes"].as_bool().unwrap());
1058 assert!(result["diff"].as_str().unwrap().contains("modified"));
1059 }
1060
1061 #[tokio::test]
1062 async fn test_git_log_with_commits() {
1063 let temp_dir = setup_git_repo().await;
1064 std::fs::write(temp_dir.path().join("a.txt"), "a").unwrap();
1065 Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1066 Command::new("git").args(["commit", "-m", "first commit"]).current_dir(temp_dir.path()).output().await.unwrap();
1067 std::fs::write(temp_dir.path().join("b.txt"), "b").unwrap();
1068 Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1069 Command::new("git").args(["commit", "-m", "second commit"]).current_dir(temp_dir.path()).output().await.unwrap();
1070
1071 let tool = GitLogTool::new(temp_dir.path().into());
1072 let result = tool.execute(json!({"count": 5})).await.unwrap();
1073 assert!(result["success"].as_bool().unwrap());
1074 let log = result["log"].as_str().unwrap();
1075 assert!(log.contains("first commit"));
1076 assert!(log.contains("second commit"));
1077 }
1078
1079 #[tokio::test]
1080 async fn test_git_branch_list() {
1081 let temp_dir = setup_git_repo().await;
1082 std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
1083 Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1084 Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
1085
1086 let tool = GitBranchTool::new(temp_dir.path().into());
1087 let result = tool.execute(json!({})).await.unwrap();
1088 assert!(result["success"].as_bool().unwrap());
1089 let branches = result["branches"].as_array().unwrap();
1090 assert!(!branches.is_empty(), "Should have at least one branch");
1091 assert!(result["current"].as_str().is_some());
1092 }
1093
1094 #[tokio::test]
1095 async fn test_git_checkout_create_branch() {
1096 let temp_dir = setup_git_repo().await;
1097 std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
1098 Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1099 Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
1100
1101 let tool = GitCheckoutTool::new(temp_dir.path().into());
1102 let result = tool.execute(json!({"target": "feature-test", "create": true})).await.unwrap();
1103 assert!(result["success"].as_bool().unwrap());
1104
1105 let branch_tool = GitBranchTool::new(temp_dir.path().into());
1107 let branches = branch_tool.execute(json!({})).await.unwrap();
1108 assert_eq!(branches["current"].as_str().unwrap(), "feature-test");
1109 }
1110
1111 #[tokio::test]
1112 async fn test_git_stash_on_clean_repo() {
1113 let temp_dir = setup_git_repo().await;
1114 let tool = GitStashTool::new(temp_dir.path().into());
1115 let result = tool.execute(json!({"action": "list"})).await.unwrap();
1117 assert!(result["success"].as_bool().unwrap());
1118 }
1119
1120 #[tokio::test]
1121 async fn test_git_blame_requires_file() {
1122 let temp_dir = setup_git_repo().await;
1123 let tool = GitBlameTool::new(temp_dir.path().into());
1124 let result = tool.execute(json!({})).await;
1125 assert!(result.is_err(), "blame without file should error");
1126 }
1127
1128 #[tokio::test]
1129 async fn test_git_tool_schemas() {
1130 let tmp = TempDir::new().unwrap();
1131 let tools: Vec<(&str, Box<dyn Tool>)> = vec![
1133 ("git_status", Box::new(GitStatusTool::new(tmp.path().into()))),
1134 ("git_diff", Box::new(GitDiffTool::new(tmp.path().into()))),
1135 ("git_add", Box::new(GitAddTool::new(tmp.path().into()))),
1136 ("git_commit", Box::new(GitCommitTool::new(tmp.path().into()))),
1137 ("git_log", Box::new(GitLogTool::new(tmp.path().into()))),
1138 ("git_blame", Box::new(GitBlameTool::new(tmp.path().into()))),
1139 ("git_branch", Box::new(GitBranchTool::new(tmp.path().into()))),
1140 ("git_checkout", Box::new(GitCheckoutTool::new(tmp.path().into()))),
1141 ("git_stash", Box::new(GitStashTool::new(tmp.path().into()))),
1142 ];
1143 for (expected_name, tool) in &tools {
1144 assert_eq!(tool.name(), *expected_name, "Tool name mismatch");
1145 assert!(!tool.description().is_empty(), "Missing description for {}", expected_name);
1146 let schema = tool.parameters_schema();
1147 assert!(schema.is_object(), "Schema should be object for {}", expected_name);
1148 }
1149 }
1150}