use crate::ai::truncator::Truncator;
use crate::toolbox::SashikoToolContext;
use crate::toolbox::framework::LlmTool;
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use serde_json::{Value, json};
use tokio::process::Command;
pub struct GitBlameTool;
#[async_trait]
impl LlmTool<SashikoToolContext> for GitBlameTool {
fn name(&self) -> &'static str {
"git_blame"
}
fn description(&self) -> &'static str {
"Show what revision and author last modified each line of a file."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"revision": { "type": "string", "description": "The Git commit SHA or reference to blame from." },
"path": { "type": "string", "description": "Relative path to the file." },
"start_line": { "type": "integer", "description": "1-based start line (optional)." },
"end_line": { "type": "integer", "description": "1-based end line (optional)." }
},
"required": ["revision", "path"]
})
}
fn normalize_args(&self, args: &Value) -> Value {
let mut normalized = args.clone();
if let Some(obj) = normalized.as_object_mut() {
if !obj.contains_key("start_line") {
obj.insert("start_line".to_string(), Value::Null);
}
if !obj.contains_key("end_line") {
obj.insert("end_line".to_string(), Value::Null);
}
}
normalized
}
async fn call(&self, args: Value, context: &SashikoToolContext) -> Result<Value> {
let revision_raw = args["revision"]
.as_str()
.ok_or_else(|| anyhow!("Missing revision"))?;
let revision_virt = context.virtualize_ref(revision_raw);
let revision = revision_virt.as_str();
let path_str = args["path"]
.as_str()
.ok_or_else(|| anyhow!("Missing path"))?;
let start_line = args["start_line"].as_u64();
let end_line = args["end_line"].as_u64();
let mut cmd = Command::new("git");
cmd.current_dir(&context.worktree_path).arg("blame");
if let (Some(s), Some(e)) = (start_line, end_line) {
cmd.arg(format!("-L{},{}", s, e));
}
cmd.arg(revision).arg("--").arg(path_str);
let output = cmd.output().await?;
if !output.status.success() {
return Err(anyhow!(
"git blame failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
let content = String::from_utf8_lossy(&output.stdout).to_string();
let total_blame_lines = content.lines().count();
let res = Truncator::truncate_sequential(&content, 10_000);
let truncated_content = res.content;
let lines_kept = res.lines_kept;
let is_truncated = res.truncated;
let start = start_line.unwrap_or(1);
let end_idx = if is_truncated && lines_kept > 0 {
start + lines_kept as u64 - 1
} else {
start + total_blame_lines as u64 - 1
};
let returned_items = if is_truncated && lines_kept > 0 {
lines_kept
} else {
total_blame_lines
};
Ok(json!({
"content": truncated_content,
"truncated": is_truncated,
"metadata": {
"total_items": total_blame_lines,
"returned_items": returned_items,
"start_index": start,
"end_index": end_idx
},
"next_page_hint": if is_truncated {
Some(format!("Only the first {} lines of blame are shown. To view the remaining blame lines, use start_line={}.", returned_items, start + returned_items as u64))
} else {
None
}
}))
}
}