Skip to main content

limit_cli/tools/
git.rs

1use async_trait::async_trait;
2use limit_agent::error::AgentError;
3use limit_agent::Tool;
4use serde_json::Value;
5use std::process::Command;
6
7/// Check if git is available in PATH
8fn check_git_available() -> Result<(), AgentError> {
9    let result = Command::new("git").arg("--version").output();
10
11    match result {
12        Ok(output) if output.status.success() => Ok(()),
13        Ok(_) => Err(AgentError::ToolError(
14            "git command failed to execute".to_string(),
15        )),
16        Err(_) => Err(AgentError::ToolError(
17            "git not found in PATH. Please install git 2.0 or later.".to_string(),
18        )),
19    }
20}
21
22pub struct GitStatusTool;
23
24impl GitStatusTool {
25    pub fn new() -> Self {
26        GitStatusTool
27    }
28}
29
30impl Default for GitStatusTool {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36#[async_trait]
37impl Tool for GitStatusTool {
38    fn name(&self) -> &str {
39        "git_status"
40    }
41
42    async fn execute(&self, _args: Value) -> Result<Value, AgentError> {
43        check_git_available()?;
44
45        let output = Command::new("git")
46            .args(["status", "--porcelain"])
47            .output()
48            .map_err(|e| AgentError::ToolError(format!("Failed to execute git status: {}", e)))?;
49
50        if !output.status.success() {
51            let stderr = String::from_utf8_lossy(&output.stderr);
52            return Err(AgentError::ToolError(format!(
53                "git status failed: {}",
54                stderr
55            )));
56        }
57
58        let stdout = String::from_utf8_lossy(&output.stdout);
59        let lines: Vec<&str> = stdout.lines().collect();
60
61        Ok(serde_json::json!({
62            "changes": lines,
63            "count": lines.len()
64        }))
65    }
66}
67
68pub struct GitDiffTool;
69
70impl GitDiffTool {
71    pub fn new() -> Self {
72        GitDiffTool
73    }
74}
75
76impl Default for GitDiffTool {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82#[async_trait]
83impl Tool for GitDiffTool {
84    fn name(&self) -> &str {
85        "git_diff"
86    }
87
88    async fn execute(&self, _args: Value) -> Result<Value, AgentError> {
89        check_git_available()?;
90
91        let output = Command::new("git")
92            .args(["diff"])
93            .output()
94            .map_err(|e| AgentError::ToolError(format!("Failed to execute git diff: {}", e)))?;
95
96        if !output.status.success() {
97            let stderr = String::from_utf8_lossy(&output.stderr);
98            return Err(AgentError::ToolError(format!(
99                "git diff failed: {}",
100                stderr
101            )));
102        }
103
104        let stdout = String::from_utf8_lossy(&output.stdout);
105
106        Ok(serde_json::json!({
107            "diff": stdout,
108            "size": stdout.len()
109        }))
110    }
111}
112
113pub struct GitLogTool;
114
115impl GitLogTool {
116    pub fn new() -> Self {
117        GitLogTool
118    }
119}
120
121impl Default for GitLogTool {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127#[async_trait]
128impl Tool for GitLogTool {
129    fn name(&self) -> &str {
130        "git_log"
131    }
132
133    async fn execute(&self, args: Value) -> Result<Value, AgentError> {
134        check_git_available()?;
135
136        // Get number of commits from args, default to 10
137        let count = args.get("count").and_then(|v| v.as_u64()).unwrap_or(10);
138
139        let output = Command::new("git")
140            .args(["log", &format!("-{}", count), "--oneline"])
141            .output()
142            .map_err(|e| AgentError::ToolError(format!("Failed to execute git log: {}", e)))?;
143
144        if !output.status.success() {
145            let stderr = String::from_utf8_lossy(&output.stderr);
146            return Err(AgentError::ToolError(format!("git log failed: {}", stderr)));
147        }
148
149        let stdout = String::from_utf8_lossy(&output.stdout);
150        let commits: Vec<&str> = stdout.lines().collect();
151
152        Ok(serde_json::json!({
153            "commits": commits,
154            "count": commits.len()
155        }))
156    }
157}
158
159pub struct GitAddTool;
160
161impl GitAddTool {
162    pub fn new() -> Self {
163        GitAddTool
164    }
165}
166
167impl Default for GitAddTool {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173#[async_trait]
174impl Tool for GitAddTool {
175    fn name(&self) -> &str {
176        "git_add"
177    }
178
179    async fn execute(&self, args: Value) -> Result<Value, AgentError> {
180        check_git_available()?;
181
182        let files: Vec<String> = serde_json::from_value(args["files"].clone())
183            .map_err(|e| AgentError::ToolError(format!("Invalid files argument: {}", e)))?;
184
185        if files.is_empty() {
186            return Err(AgentError::ToolError(
187                "files argument cannot be empty".to_string(),
188            ));
189        }
190
191        let mut cmd = Command::new("git");
192        cmd.arg("add");
193        for file in &files {
194            cmd.arg(file);
195        }
196
197        let output = cmd
198            .output()
199            .map_err(|e| AgentError::ToolError(format!("Failed to execute git add: {}", e)))?;
200
201        if !output.status.success() {
202            let stderr = String::from_utf8_lossy(&output.stderr);
203            return Err(AgentError::ToolError(format!("git add failed: {}", stderr)));
204        }
205
206        Ok(serde_json::json!({
207            "success": true,
208            "files": files,
209            "count": files.len()
210        }))
211    }
212}
213
214pub struct GitCommitTool;
215
216impl GitCommitTool {
217    pub fn new() -> Self {
218        GitCommitTool
219    }
220}
221
222impl Default for GitCommitTool {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228#[async_trait]
229impl Tool for GitCommitTool {
230    fn name(&self) -> &str {
231        "git_commit"
232    }
233
234    async fn execute(&self, args: Value) -> Result<Value, AgentError> {
235        check_git_available()?;
236
237        let message: String = serde_json::from_value(args["message"].clone())
238            .map_err(|e| AgentError::ToolError(format!("Invalid message argument: {}", e)))?;
239
240        if message.trim().is_empty() {
241            return Err(AgentError::ToolError(
242                "message argument cannot be empty".to_string(),
243            ));
244        }
245
246        let output = Command::new("git")
247            .args(["commit", "-m", &message])
248            .output()
249            .map_err(|e| AgentError::ToolError(format!("Failed to execute git commit: {}", e)))?;
250
251        if !output.status.success() {
252            let stderr = String::from_utf8_lossy(&output.stderr);
253            return Err(AgentError::ToolError(format!(
254                "git commit failed: {}",
255                stderr
256            )));
257        }
258
259        let stdout = String::from_utf8_lossy(&output.stdout);
260
261        Ok(serde_json::json!({
262            "success": true,
263            "message": message,
264            "output": stdout
265        }))
266    }
267}
268
269pub struct GitPushTool;
270
271impl GitPushTool {
272    pub fn new() -> Self {
273        GitPushTool
274    }
275}
276
277impl Default for GitPushTool {
278    fn default() -> Self {
279        Self::new()
280    }
281}
282
283#[async_trait]
284impl Tool for GitPushTool {
285    fn name(&self) -> &str {
286        "git_push"
287    }
288
289    async fn execute(&self, args: Value) -> Result<Value, AgentError> {
290        check_git_available()?;
291
292        // Get remote and branch from args, use defaults
293        let remote = args
294            .get("remote")
295            .and_then(|v| v.as_str())
296            .unwrap_or("origin");
297
298        let branch = args.get("branch").and_then(|v| v.as_str()).unwrap_or("");
299
300        let output = if branch.is_empty() {
301            Command::new("git")
302                .args(["push", remote])
303                .output()
304                .map_err(|e| AgentError::ToolError(format!("Failed to execute git push: {}", e)))?
305        } else {
306            Command::new("git")
307                .args(["push", remote, branch])
308                .output()
309                .map_err(|e| AgentError::ToolError(format!("Failed to execute git push: {}", e)))?
310        };
311
312        if !output.status.success() {
313            let stderr = String::from_utf8_lossy(&output.stderr);
314            return Err(AgentError::ToolError(format!(
315                "git push failed: {}",
316                stderr
317            )));
318        }
319
320        let stdout = String::from_utf8_lossy(&output.stdout);
321
322        Ok(serde_json::json!({
323            "success": true,
324            "remote": remote,
325            "branch": if branch.is_empty() { "(default)" } else { branch },
326            "output": stdout
327        }))
328    }
329}
330
331pub struct GitPullTool;
332
333impl GitPullTool {
334    pub fn new() -> Self {
335        GitPullTool
336    }
337}
338
339impl Default for GitPullTool {
340    fn default() -> Self {
341        Self::new()
342    }
343}
344
345#[async_trait]
346impl Tool for GitPullTool {
347    fn name(&self) -> &str {
348        "git_pull"
349    }
350
351    async fn execute(&self, args: Value) -> Result<Value, AgentError> {
352        check_git_available()?;
353
354        // Get remote and branch from args, use defaults
355        let remote = args
356            .get("remote")
357            .and_then(|v| v.as_str())
358            .unwrap_or("origin");
359
360        let branch = args.get("branch").and_then(|v| v.as_str()).unwrap_or("");
361
362        let output = if branch.is_empty() {
363            Command::new("git")
364                .args(["pull", remote])
365                .output()
366                .map_err(|e| AgentError::ToolError(format!("Failed to execute git pull: {}", e)))?
367        } else {
368            Command::new("git")
369                .args(["pull", remote, branch])
370                .output()
371                .map_err(|e| AgentError::ToolError(format!("Failed to execute git pull: {}", e)))?
372        };
373
374        if !output.status.success() {
375            let stderr = String::from_utf8_lossy(&output.stderr);
376            return Err(AgentError::ToolError(format!(
377                "git pull failed: {}",
378                stderr
379            )));
380        }
381
382        let stdout = String::from_utf8_lossy(&output.stdout);
383
384        Ok(serde_json::json!({
385            "success": true,
386            "remote": remote,
387            "branch": if branch.is_empty() { "(default)" } else { branch },
388            "output": stdout
389        }))
390    }
391}
392
393pub struct GitCloneTool;
394
395impl GitCloneTool {
396    pub fn new() -> Self {
397        GitCloneTool
398    }
399}
400
401impl Default for GitCloneTool {
402    fn default() -> Self {
403        Self::new()
404    }
405}
406
407#[async_trait]
408impl Tool for GitCloneTool {
409    fn name(&self) -> &str {
410        "git_clone"
411    }
412
413    async fn execute(&self, args: Value) -> Result<Value, AgentError> {
414        check_git_available()?;
415
416        let url: String = serde_json::from_value(args["url"].clone())
417            .map_err(|e| AgentError::ToolError(format!("Invalid url argument: {}", e)))?;
418
419        if url.trim().is_empty() {
420            return Err(AgentError::ToolError(
421                "url argument cannot be empty".to_string(),
422            ));
423        }
424
425        // Get optional directory name
426        let directory = args.get("directory").and_then(|v| v.as_str());
427
428        let output = if let Some(dir) = directory {
429            Command::new("git")
430                .args(["clone", &url, dir])
431                .output()
432                .map_err(|e| AgentError::ToolError(format!("Failed to execute git clone: {}", e)))?
433        } else {
434            Command::new("git")
435                .args(["clone", &url])
436                .output()
437                .map_err(|e| AgentError::ToolError(format!("Failed to execute git clone: {}", e)))?
438        };
439
440        if !output.status.success() {
441            let stderr = String::from_utf8_lossy(&output.stderr);
442            return Err(AgentError::ToolError(format!(
443                "git clone failed: {}",
444                stderr
445            )));
446        }
447
448        let stdout = String::from_utf8_lossy(&output.stdout);
449
450        Ok(serde_json::json!({
451            "success": true,
452            "url": url,
453            "directory": directory.unwrap_or("(default)"),
454            "output": stdout
455        }))
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    #[tokio::test]
464    async fn test_git_status_tool_name() {
465        let tool = GitStatusTool::new();
466        assert_eq!(tool.name(), "git_status");
467    }
468
469    #[tokio::test]
470    async fn test_git_status_tool_default() {
471        let tool = GitStatusTool;
472        assert_eq!(tool.name(), "git_status");
473    }
474
475    #[tokio::test]
476    async fn test_git_diff_tool_name() {
477        let tool = GitDiffTool::new();
478        assert_eq!(tool.name(), "git_diff");
479    }
480
481    #[tokio::test]
482    async fn test_git_log_tool_name() {
483        let tool = GitLogTool::new();
484        assert_eq!(tool.name(), "git_log");
485    }
486
487    #[tokio::test]
488    async fn test_git_log_tool_default_count() {
489        let tool = GitLogTool::new();
490        let args = serde_json::json!({});
491
492        // This will fail if git is not in a repository, but we test the parsing logic
493        // The actual execution requires a git repository context
494        let result = tool.execute(args).await;
495
496        // We expect either success (in a git repo) or a git-specific error, not a parsing error
497        // If git is not available, we get a different error
498        if let Err(e) = result {
499            assert!(!e.to_string().contains("Invalid count argument"));
500        }
501    }
502
503    #[tokio::test]
504    async fn test_git_log_tool_custom_count() {
505        let tool = GitLogTool::new();
506        let args = serde_json::json!({"count": 5});
507
508        let result = tool.execute(args).await;
509
510        // Similar to above, we test parsing, not actual git execution
511        if let Err(e) = result {
512            assert!(!e.to_string().contains("Invalid count argument"));
513        }
514    }
515
516    #[tokio::test]
517    async fn test_git_add_tool_name() {
518        let tool = GitAddTool::new();
519        assert_eq!(tool.name(), "git_add");
520    }
521
522    #[tokio::test]
523    async fn test_git_add_tool_empty_files() {
524        let tool = GitAddTool::new();
525        let args = serde_json::json!({"files": []});
526
527        let result = tool.execute(args).await;
528        assert!(result.is_err());
529        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
530    }
531
532    #[tokio::test]
533    async fn test_git_add_tool_invalid_files() {
534        let tool = GitAddTool::new();
535        let args = serde_json::json!({}); // Missing files
536
537        let result = tool.execute(args).await;
538        assert!(result.is_err());
539        assert!(result.unwrap_err().to_string().contains("Invalid files"));
540    }
541
542    #[tokio::test]
543    async fn test_git_commit_tool_name() {
544        let tool = GitCommitTool::new();
545        assert_eq!(tool.name(), "git_commit");
546    }
547
548    #[tokio::test]
549    async fn test_git_commit_tool_empty_message() {
550        let tool = GitCommitTool::new();
551        let args = serde_json::json!({"message": "   "});
552
553        let result = tool.execute(args).await;
554        assert!(result.is_err());
555        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
556    }
557
558    #[tokio::test]
559    async fn test_git_commit_tool_invalid_message() {
560        let tool = GitCommitTool::new();
561        let args = serde_json::json!({}); // Missing message
562
563        let result = tool.execute(args).await;
564        assert!(result.is_err());
565        assert!(result.unwrap_err().to_string().contains("Invalid message"));
566    }
567
568    #[tokio::test]
569    async fn test_git_push_tool_name() {
570        let tool = GitPushTool::new();
571        assert_eq!(tool.name(), "git_push");
572    }
573
574    #[tokio::test]
575    async fn test_git_push_tool_default_values() {
576        let tool = GitPushTool::new();
577        let args = serde_json::json!({});
578
579        let result = tool.execute(args).await;
580
581        // Will fail in most cases, but tests parsing
582        if let Err(e) = result {
583            // Should not be a parsing error
584            assert!(!e.to_string().contains("Invalid"));
585        }
586    }
587
588    #[tokio::test]
589    async fn test_git_push_tool_custom_values() {
590        let tool = GitPushTool::new();
591        let args = serde_json::json!({
592            "remote": "upstream",
593            "branch": "feature"
594        });
595
596        let result = tool.execute(args).await;
597
598        // Tests that custom values are parsed correctly
599        if let Err(e) = result {
600            assert!(!e.to_string().contains("Invalid"));
601        }
602    }
603
604    #[tokio::test]
605    async fn test_git_pull_tool_name() {
606        let tool = GitPullTool::new();
607        assert_eq!(tool.name(), "git_pull");
608    }
609
610    #[tokio::test]
611    async fn test_git_pull_tool_default_values() {
612        let tool = GitPullTool::new();
613        let args = serde_json::json!({});
614
615        let result = tool.execute(args).await;
616
617        // Will fail in most cases, but tests parsing
618        if let Err(e) = result {
619            // Should not be a parsing error
620            assert!(!e.to_string().contains("Invalid"));
621        }
622    }
623
624    #[tokio::test]
625    async fn test_git_clone_tool_name() {
626        let tool = GitCloneTool::new();
627        assert_eq!(tool.name(), "git_clone");
628    }
629
630    #[tokio::test]
631    async fn test_git_clone_tool_empty_url() {
632        let tool = GitCloneTool::new();
633        let args = serde_json::json!({"url": ""});
634
635        let result = tool.execute(args).await;
636        assert!(result.is_err());
637        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
638    }
639
640    #[tokio::test]
641    async fn test_git_clone_tool_invalid_url() {
642        let tool = GitCloneTool::new();
643        let args = serde_json::json!({}); // Missing url
644
645        let result = tool.execute(args).await;
646        assert!(result.is_err());
647        assert!(result.unwrap_err().to_string().contains("Invalid url"));
648    }
649
650    #[tokio::test]
651    async fn test_git_clone_tool_custom_directory() {
652        let tool = GitCloneTool::new();
653        let args = serde_json::json!({
654            "url": "https://github.com/test/repo.git",
655            "directory": "my-repo"
656        });
657
658        let result = tool.execute(args).await;
659
660        // Tests that directory argument is parsed correctly
661        if let Err(e) = result {
662            // Should not be a parsing error
663            assert!(!e.to_string().contains("Invalid"));
664        }
665    }
666
667    #[tokio::test]
668    async fn test_all_tools_implement_default() {
669        // Verify all tools implement Default trait
670        let _status = GitStatusTool;
671        let _diff = GitDiffTool;
672        let _log = GitLogTool;
673        let _add = GitAddTool;
674        let _commit = GitCommitTool;
675        let _push = GitPushTool;
676        let _pull = GitPullTool;
677        let _clone = GitCloneTool;
678    }
679}