返回 CodeWhale
git_tool.rs
根目录 / crates / tui / src / tools / git_tool.rs
1 //! Canonical action-based wrapper for git inspection tools.
2 //!
3 //! The model sees one tool: `Git` with an `action` parameter
4 //! (status | diff | log | show | blame). The per-action legacy execution
5 //! aliases were removed in v0.9.3.
6
7 use async_trait::async_trait;
8 use serde_json::{Value, json};
9
10 use super::canonical_action::required_action;
11 use super::git::{GitDiffTool, GitStatusTool};
12 use super::git_history::{GitBlameTool, GitLogTool, GitShowTool};
13 use super::spec::{
14 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
15 };
16
17 pub struct GitTool {
18 name: &'static str,
19 forced_action: Option<&'static str>,
20 }
21
22 impl GitTool {
23 pub const fn new(name: &'static str) -> Self {
24 Self {
25 name,
26 forced_action: None,
27 }
28 }
29
30 const ACTIONS: &'static [&'static str] = &["status", "diff", "log", "show", "blame"];
31
32 fn required_action(&self, input: &Value) -> Result<String, ToolError> {
33 if let Some(forced) = self.forced_action {
34 return Ok(forced.to_string());
35 }
36 required_action(input, self.name, Self::ACTIONS)
37 }
38
39 fn strip_action(&self, input: Value) -> Result<Value, ToolError> {
40 let mut input = input;
41 if let Some(obj) = input.as_object_mut() {
42 obj.remove("action");
43 Ok(input)
44 } else {
45 Err(ToolError::invalid_input(
46 "Git tool input must be a JSON object, e.g. {\"action\": \"status\"}",
47 ))
48 }
49 }
50 }
51
52 #[async_trait]
53 impl ToolSpec for GitTool {
54 fn name(&self) -> &'static str {
55 self.name
56 }
57
58 fn model_visible(&self) -> bool {
59 self.name == "Git"
60 }
61
62 fn description(&self) -> &'static str {
63 "Inspect repository state and history with status, diff, log, show, or blame. All actions are read-only and parallel-safe."
64 }
65
66 fn input_schema(&self) -> Value {
67 json!({
68 "type": "object",
69 "properties": {
70 "action": {
71 "type": "string",
72 "enum": ["status", "diff", "log", "show", "blame"],
73 "description": "Action to perform"
74 },
75 "path": {
76 "type": "string",
77 "description": "Optional subdirectory or file path to scope the git command"
78 },
79 "cached": {
80 "type": "boolean",
81 "description": "When true, diff staged changes (action=diff)"
82 },
83 "unified": {
84 "type": "integer",
85 "description": "Number of context lines for diff or show output"
86 },
87 "max_count": {
88 "type": "integer",
89 "description": "Maximum commits to return (action=log)"
90 },
91 "author": {
92 "type": "string",
93 "description": "Author filter (action=log)"
94 },
95 "since": {
96 "type": "string",
97 "description": "Lower date bound (action=log)"
98 },
99 "until": {
100 "type": "string",
101 "description": "Upper date bound (action=log)"
102 },
103 "rev": {
104 "type": "string",
105 "description": "Revision to show (action=show) or blame against (action=blame)"
106 },
107 "patch": {
108 "type": "boolean",
109 "description": "Include patch hunks (action=show)"
110 },
111 "stat": {
112 "type": "boolean",
113 "description": "Include stat summary (action=show)"
114 },
115 "start_line": {
116 "type": "integer",
117 "description": "First line to include (action=blame)"
118 },
119 "max_lines": {
120 "type": "integer",
121 "description": "Maximum lines to include (action=blame)"
122 },
123 "porcelain": {
124 "type": "boolean",
125 "description": "Emit line-porcelain output (action=blame)"
126 }
127 },
128 "required": ["action"]
129 })
130 }
131
132 fn capabilities(&self) -> Vec<ToolCapability> {
133 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
134 }
135
136 fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement {
137 ApprovalRequirement::Auto
138 }
139
140 fn is_read_only_for(&self, _input: &Value) -> bool {
141 true
142 }
143
144 fn supports_parallel_for(&self, _input: &Value) -> bool {
145 true
146 }
147
148 fn starts_detached_for(&self, _input: &Value) -> bool {
149 false
150 }
151
152 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
153 let action = self.required_action(&input)?;
154 let input = self.strip_action(input)?;
155
156 match action.as_str() {
157 "status" => GitStatusTool.execute(input, context).await,
158 "diff" => GitDiffTool.execute(input, context).await,
159 "log" => GitLogTool.execute(input, context).await,
160 "show" => GitShowTool.execute(input, context).await,
161 "blame" => GitBlameTool.execute(input, context).await,
162 other => Err(ToolError::invalid_input(format!(
163 "Unknown Git action \"{other}\"; nothing was run. Pass one of: {}.",
164 Self::ACTIONS.join(", ")
165 ))),
166 }
167 }
168 }
169
170 #[cfg(test)]
171 mod tests {
172 use super::*;
173 use serde_json::json;
174 use tempfile::tempdir;
175
176 async fn err(input: Value) -> String {
177 let tmp = tempdir().expect("tempdir");
178 let ctx = ToolContext::new(tmp.path().to_path_buf());
179 GitTool::new("Git")
180 .execute(input, &ctx)
181 .await
182 .expect_err("call must be refused")
183 .to_string()
184 }
185
186 #[tokio::test]
187 async fn missing_action_is_refused_with_the_valid_values() {
188 let message = err(json!({"path": "src"})).await;
189 assert!(message.contains("requires an `action`"), "{message}");
190 assert!(message.contains("nothing was run"), "{message}");
191 assert!(message.contains("blame"), "{message}");
192 }
193
194 #[tokio::test]
195 async fn unknown_action_names_the_actions_that_dispatch() {
196 let message = err(json!({"action": "commit"})).await;
197 assert!(message.contains("commit"), "{message}");
198 assert!(
199 message.contains("status, diff, log, show, blame"),
200 "{message}"
201 );
202 }
203
204 #[test]
205 fn advertised_actions_match_the_actions_that_dispatch() {
206 let schema = GitTool::new("Git").input_schema();
207 let advertised: Vec<&str> = schema["properties"]["action"]["enum"]
208 .as_array()
209 .expect("action enum")
210 .iter()
211 .map(|value| value.as_str().expect("string"))
212 .collect();
213 assert_eq!(advertised, GitTool::ACTIONS);
214 }
215 }
216
216 lines RUST