use async_trait::async_trait;
use serde_json::{Value, json};
use super::canonical_action::required_action;
use super::git::{GitDiffTool, GitStatusTool};
use super::git_history::{GitBlameTool, GitLogTool, GitShowTool};
use super::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
pub struct GitTool {
name: &'static str,
forced_action: Option<&'static str>,
}
impl GitTool {
pub const fn new(name: &'static str) -> Self {
Self {
name,
forced_action: None,
}
}
const ACTIONS: &'static [&'static str] = &["status", "diff", "log", "show", "blame"];
fn required_action(&self, input: &Value) -> Result<String, ToolError> {
if let Some(forced) = self.forced_action {
return Ok(forced.to_string());
}
required_action(input, self.name, Self::ACTIONS)
}
fn strip_action(&self, input: Value) -> Result<Value, ToolError> {
let mut input = input;
if let Some(obj) = input.as_object_mut() {
obj.remove("action");
Ok(input)
} else {
Err(ToolError::invalid_input(
"Git tool input must be a JSON object, e.g. {\"action\": \"status\"}",
))
}
}
}
#[async_trait]
impl ToolSpec for GitTool {
fn name(&self) -> &'static str {
self.name
}
fn model_visible(&self) -> bool {
self.name == "Git"
}
fn description(&self) -> &'static str {
"Inspect repository state and history with status, diff, log, show, or blame. All actions are read-only and parallel-safe."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["status", "diff", "log", "show", "blame"],
"description": "Action to perform"
},
"path": {
"type": "string",
"description": "Optional subdirectory or file path to scope the git command"
},
"cached": {
"type": "boolean",
"description": "When true, diff staged changes (action=diff)"
},
"unified": {
"type": "integer",
"description": "Number of context lines for diff or show output"
},
"max_count": {
"type": "integer",
"description": "Maximum commits to return (action=log)"
},
"author": {
"type": "string",
"description": "Author filter (action=log)"
},
"since": {
"type": "string",
"description": "Lower date bound (action=log)"
},
"until": {
"type": "string",
"description": "Upper date bound (action=log)"
},
"rev": {
"type": "string",
"description": "Revision to show (action=show) or blame against (action=blame)"
},
"patch": {
"type": "boolean",
"description": "Include patch hunks (action=show)"
},
"stat": {
"type": "boolean",
"description": "Include stat summary (action=show)"
},
"start_line": {
"type": "integer",
"description": "First line to include (action=blame)"
},
"max_lines": {
"type": "integer",
"description": "Maximum lines to include (action=blame)"
},
"porcelain": {
"type": "boolean",
"description": "Emit line-porcelain output (action=blame)"
}
},
"required": ["action"]
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
}
fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
fn is_read_only_for(&self, _input: &Value) -> bool {
true
}
fn supports_parallel_for(&self, _input: &Value) -> bool {
true
}
fn starts_detached_for(&self, _input: &Value) -> bool {
false
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let action = self.required_action(&input)?;
let input = self.strip_action(input)?;
match action.as_str() {
"status" => GitStatusTool.execute(input, context).await,
"diff" => GitDiffTool.execute(input, context).await,
"log" => GitLogTool.execute(input, context).await,
"show" => GitShowTool.execute(input, context).await,
"blame" => GitBlameTool.execute(input, context).await,
other => Err(ToolError::invalid_input(format!(
"Unknown Git action \"{other}\"; nothing was run. Pass one of: {}.",
Self::ACTIONS.join(", ")
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::tempdir;
async fn err(input: Value) -> String {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
GitTool::new("Git")
.execute(input, &ctx)
.await
.expect_err("call must be refused")
.to_string()
}
#[tokio::test]
async fn missing_action_is_refused_with_the_valid_values() {
let message = err(json!({"path": "src"})).await;
assert!(message.contains("requires an `action`"), "{message}");
assert!(message.contains("nothing was run"), "{message}");
assert!(message.contains("blame"), "{message}");
}
#[tokio::test]
async fn unknown_action_names_the_actions_that_dispatch() {
let message = err(json!({"action": "commit"})).await;
assert!(message.contains("commit"), "{message}");
assert!(
message.contains("status, diff, log, show, blame"),
"{message}"
);
}
#[test]
fn advertised_actions_match_the_actions_that_dispatch() {
let schema = GitTool::new("Git").input_schema();
let advertised: Vec<&str> = schema["properties"]["action"]["enum"]
.as_array()
.expect("action enum")
.iter()
.map(|value| value.as_str().expect("string"))
.collect();
assert_eq!(advertised, GitTool::ACTIONS);
}
}