release: 0.3.27 add git_history and validate_data tools
This commit is contained in:
Generated
+1
-1
@@ -747,7 +747,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.26"
|
||||
version = "0.3.27"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.26"
|
||||
version = "0.3.27"
|
||||
edition = "2024"
|
||||
description = "Terminal-native TUI and CLI for DeepSeek models"
|
||||
license = "MIT"
|
||||
|
||||
@@ -1446,7 +1446,9 @@ impl Engine {
|
||||
.with_read_only_file_tools()
|
||||
.with_search_tools()
|
||||
.with_git_tools()
|
||||
.with_git_history_tools()
|
||||
.with_diagnostics_tool()
|
||||
.with_validation_tools()
|
||||
.with_todo_tool(todo_list.clone())
|
||||
.with_plan_tool(plan_state.clone())
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
//! Git history tools: `git_log`, `git_show`, and `git_blame`.
|
||||
//!
|
||||
//! These tools provide read-only access to commit history and attribution
|
||||
//! without exposing arbitrary shell execution.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_bool, optional_str, optional_u64, required_str,
|
||||
};
|
||||
|
||||
const MAX_OUTPUT_CHARS: usize = 40_000;
|
||||
const DEFAULT_LOG_MAX_COUNT: u64 = 20;
|
||||
const MAX_LOG_MAX_COUNT: u64 = 200;
|
||||
const DEFAULT_UNIFIED: u64 = 3;
|
||||
const MAX_UNIFIED: u64 = 50;
|
||||
const DEFAULT_BLAME_START_LINE: u64 = 1;
|
||||
const DEFAULT_BLAME_MAX_LINES: u64 = 200;
|
||||
const MAX_BLAME_MAX_LINES: u64 = 2_000;
|
||||
|
||||
/// Tool for reading recent commit history.
|
||||
pub struct GitLogTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for GitLogTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_log"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Run `git log` in the workspace with optional path and author/date filters."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Optional subdirectory or file path to scope history to."
|
||||
},
|
||||
"max_count": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": MAX_LOG_MAX_COUNT,
|
||||
"default": DEFAULT_LOG_MAX_COUNT,
|
||||
"description": "Maximum number of commits to return."
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"description": "Optional git author filter (same semantics as `git log --author`)."
|
||||
},
|
||||
"since": {
|
||||
"type": "string",
|
||||
"description": "Optional lower date bound, e.g. '2 weeks ago' or ISO date."
|
||||
},
|
||||
"until": {
|
||||
"type": "string",
|
||||
"description": "Optional upper date bound, e.g. 'yesterday' or ISO date."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let git_ctx = resolve_git_context(context, optional_str(&input, "path"))?;
|
||||
let max_count =
|
||||
optional_u64(&input, "max_count", DEFAULT_LOG_MAX_COUNT).clamp(1, MAX_LOG_MAX_COUNT);
|
||||
let author = optional_str(&input, "author").map(ToOwned::to_owned);
|
||||
let since = optional_str(&input, "since").map(ToOwned::to_owned);
|
||||
let until = optional_str(&input, "until").map(ToOwned::to_owned);
|
||||
|
||||
let mut args = vec![
|
||||
"log".to_string(),
|
||||
"--no-color".to_string(),
|
||||
format!("--max-count={max_count}"),
|
||||
"--date=iso-strict".to_string(),
|
||||
"--pretty=format:%H%nAuthor: %an <%ae>%nDate: %ad%nSubject: %s%n".to_string(),
|
||||
];
|
||||
if let Some(author) = &author {
|
||||
args.push(format!("--author={author}"));
|
||||
}
|
||||
if let Some(since) = &since {
|
||||
args.push(format!("--since={since}"));
|
||||
}
|
||||
if let Some(until) = &until {
|
||||
args.push(format!("--until={until}"));
|
||||
}
|
||||
if let Some(pathspec) = &git_ctx.pathspec {
|
||||
args.push("--".to_string());
|
||||
args.push(pathspec.display().to_string());
|
||||
}
|
||||
|
||||
let command_str = format_command(&git_ctx.working_dir, &args);
|
||||
let output = run_git_command(&git_ctx.working_dir, &args)?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Ok(
|
||||
ToolResult::error(format!("git log failed: {}", stderr.trim())).with_metadata(
|
||||
json!({
|
||||
"command": command_str,
|
||||
"exit_code": output.status.code(),
|
||||
"stderr": stderr.trim(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
|
||||
Ok(ToolResult::success(content).with_metadata(json!({
|
||||
"command": command_str,
|
||||
"working_dir": git_ctx.working_dir,
|
||||
"pathspec": git_ctx.pathspec,
|
||||
"max_count": max_count,
|
||||
"author": author,
|
||||
"since": since,
|
||||
"until": until,
|
||||
"truncated": truncated,
|
||||
"omitted_chars": omitted_chars,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for showing a specific commit with optional patch/stat output.
|
||||
pub struct GitShowTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for GitShowTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_show"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Run `git show` for a specific revision with optional patch and stats."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rev": {
|
||||
"type": "string",
|
||||
"description": "Revision to show (commit SHA, tag, branch, or ref expression)."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Optional subdirectory or file path to scope output."
|
||||
},
|
||||
"patch": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Include patch hunks (default true)."
|
||||
},
|
||||
"stat": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Include --stat summary (default true)."
|
||||
},
|
||||
"unified": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": MAX_UNIFIED,
|
||||
"default": DEFAULT_UNIFIED,
|
||||
"description": "Context lines for patch output when patch=true."
|
||||
}
|
||||
},
|
||||
"required": ["rev"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let rev = required_str(&input, "rev")?;
|
||||
let git_ctx = resolve_git_context(context, optional_str(&input, "path"))?;
|
||||
let patch = optional_bool(&input, "patch", true);
|
||||
let stat = optional_bool(&input, "stat", true);
|
||||
let unified = optional_u64(&input, "unified", DEFAULT_UNIFIED).min(MAX_UNIFIED);
|
||||
|
||||
let mut args = vec![
|
||||
"show".to_string(),
|
||||
"--no-color".to_string(),
|
||||
"--no-ext-diff".to_string(),
|
||||
];
|
||||
if patch {
|
||||
args.push(format!("--unified={unified}"));
|
||||
} else {
|
||||
args.push("--no-patch".to_string());
|
||||
}
|
||||
if stat {
|
||||
args.push("--stat".to_string());
|
||||
}
|
||||
args.push(rev.to_string());
|
||||
if let Some(pathspec) = &git_ctx.pathspec {
|
||||
args.push("--".to_string());
|
||||
args.push(pathspec.display().to_string());
|
||||
}
|
||||
|
||||
let command_str = format_command(&git_ctx.working_dir, &args);
|
||||
let output = run_git_command(&git_ctx.working_dir, &args)?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Ok(ToolResult::error(format!(
|
||||
"git show failed for '{rev}': {}",
|
||||
stderr.trim()
|
||||
))
|
||||
.with_metadata(json!({
|
||||
"command": command_str,
|
||||
"exit_code": output.status.code(),
|
||||
"stderr": stderr.trim(),
|
||||
})));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
|
||||
Ok(ToolResult::success(content).with_metadata(json!({
|
||||
"command": command_str,
|
||||
"working_dir": git_ctx.working_dir,
|
||||
"pathspec": git_ctx.pathspec,
|
||||
"rev": rev,
|
||||
"patch": patch,
|
||||
"stat": stat,
|
||||
"unified": if patch { Some(unified) } else { None },
|
||||
"truncated": truncated,
|
||||
"omitted_chars": omitted_chars,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool for attributing lines in a file to commits and authors.
|
||||
pub struct GitBlameTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for GitBlameTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"git_blame"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Run `git blame` on a file with optional revision and line-range controls."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to a tracked file within the workspace."
|
||||
},
|
||||
"rev": {
|
||||
"type": "string",
|
||||
"description": "Optional revision to blame against (default: HEAD)."
|
||||
},
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": DEFAULT_BLAME_START_LINE,
|
||||
"description": "First line to include in blame output."
|
||||
},
|
||||
"max_lines": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": MAX_BLAME_MAX_LINES,
|
||||
"default": DEFAULT_BLAME_MAX_LINES,
|
||||
"description": "Maximum number of lines to include."
|
||||
},
|
||||
"porcelain": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "When true, emit `--line-porcelain` output."
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let path_str = required_str(&input, "path")?;
|
||||
let resolved_path = context.resolve_path(path_str)?;
|
||||
let metadata = fs::metadata(&resolved_path).map_err(|e| {
|
||||
ToolError::invalid_input(format!(
|
||||
"Path does not exist or is not accessible: {path_str} ({e})"
|
||||
))
|
||||
})?;
|
||||
if !metadata.is_file() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Path must point to a file: {path_str}"
|
||||
)));
|
||||
}
|
||||
|
||||
let working_dir = resolved_path.parent().ok_or_else(|| {
|
||||
ToolError::invalid_input(format!("Path has no parent directory: {path_str}"))
|
||||
})?;
|
||||
let pathspec = pathspec_from(working_dir, &resolved_path);
|
||||
let rev = optional_str(&input, "rev").unwrap_or("HEAD");
|
||||
let start_line = optional_u64(&input, "start_line", DEFAULT_BLAME_START_LINE).max(1);
|
||||
let max_lines = optional_u64(&input, "max_lines", DEFAULT_BLAME_MAX_LINES)
|
||||
.clamp(1, MAX_BLAME_MAX_LINES);
|
||||
let end_line = start_line.saturating_add(max_lines.saturating_sub(1));
|
||||
let porcelain = optional_bool(&input, "porcelain", false);
|
||||
|
||||
let mut args = vec![
|
||||
"blame".to_string(),
|
||||
"--date=iso".to_string(),
|
||||
format!("-L{start_line},{end_line}"),
|
||||
];
|
||||
if porcelain {
|
||||
args.push("--line-porcelain".to_string());
|
||||
}
|
||||
args.push(rev.to_string());
|
||||
args.push("--".to_string());
|
||||
args.push(pathspec.display().to_string());
|
||||
|
||||
let command_str = format_command(working_dir, &args);
|
||||
let output = run_git_command(working_dir, &args)?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Ok(ToolResult::error(format!(
|
||||
"git blame failed for '{path_str}' at '{rev}': {}",
|
||||
stderr.trim()
|
||||
))
|
||||
.with_metadata(json!({
|
||||
"command": command_str,
|
||||
"exit_code": output.status.code(),
|
||||
"stderr": stderr.trim(),
|
||||
})));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
|
||||
Ok(ToolResult::success(content).with_metadata(json!({
|
||||
"command": command_str,
|
||||
"working_dir": working_dir,
|
||||
"pathspec": pathspec,
|
||||
"rev": rev,
|
||||
"start_line": start_line,
|
||||
"max_lines": max_lines,
|
||||
"porcelain": porcelain,
|
||||
"truncated": truncated,
|
||||
"omitted_chars": omitted_chars,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
struct GitContext {
|
||||
working_dir: PathBuf,
|
||||
pathspec: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn resolve_git_context(context: &ToolContext, path: Option<&str>) -> Result<GitContext, ToolError> {
|
||||
let workspace = canonical_or_workspace(&context.workspace);
|
||||
let mut working_dir = workspace.clone();
|
||||
let mut pathspec = None;
|
||||
|
||||
if let Some(raw) = path {
|
||||
let resolved = context.resolve_path(raw)?;
|
||||
let metadata = fs::metadata(&resolved).map_err(|e| {
|
||||
ToolError::invalid_input(format!(
|
||||
"Path does not exist or is not accessible: {raw} ({e})"
|
||||
))
|
||||
})?;
|
||||
|
||||
if metadata.is_dir() {
|
||||
working_dir = resolved;
|
||||
pathspec = Some(PathBuf::from("."));
|
||||
} else {
|
||||
let parent = resolved.parent().ok_or_else(|| {
|
||||
ToolError::invalid_input(format!("Path has no parent directory: {raw}"))
|
||||
})?;
|
||||
working_dir = parent.to_path_buf();
|
||||
pathspec = Some(pathspec_from(&working_dir, &resolved));
|
||||
}
|
||||
}
|
||||
|
||||
if !working_dir.exists() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Working directory does not exist: {}",
|
||||
working_dir.display()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(GitContext {
|
||||
working_dir,
|
||||
pathspec,
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_or_workspace(workspace: &Path) -> PathBuf {
|
||||
workspace
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace.to_path_buf())
|
||||
}
|
||||
|
||||
fn pathspec_from(working_dir: &Path, resolved: &Path) -> PathBuf {
|
||||
match resolved.strip_prefix(working_dir) {
|
||||
Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
|
||||
Ok(rel) => rel.to_path_buf(),
|
||||
Err(_) => PathBuf::from("."),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_git_command(working_dir: &Path, args: &[String]) -> Result<Output, ToolError> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args(args).current_dir(working_dir);
|
||||
cmd.output().map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
ToolError::not_available("git is not installed or not in PATH")
|
||||
} else {
|
||||
ToolError::execution_failed(format!("Failed to run git: {e}"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn format_command(working_dir: &Path, args: &[String]) -> String {
|
||||
format!(
|
||||
"git -C {} {}",
|
||||
working_dir.display(),
|
||||
args.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool, usize) {
|
||||
if text.chars().count() <= max_chars {
|
||||
return (text.to_string(), false, 0);
|
||||
}
|
||||
let end = char_boundary_index(text, max_chars);
|
||||
let truncated = &text[..end];
|
||||
let omitted_chars = text
|
||||
.chars()
|
||||
.count()
|
||||
.saturating_sub(truncated.chars().count());
|
||||
let note = format!(
|
||||
"\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
|
||||
);
|
||||
(format!("{truncated}{note}"), true, omitted_chars)
|
||||
}
|
||||
|
||||
fn char_boundary_index(text: &str, max_chars: usize) -> usize {
|
||||
if max_chars == 0 {
|
||||
return 0;
|
||||
}
|
||||
for (count, (idx, _)) in text.char_indices().enumerate() {
|
||||
if count == max_chars {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
text.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn git_available() -> bool {
|
||||
Command::new("git")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn run_git(root: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(root)
|
||||
.status()
|
||||
.expect("git should spawn");
|
||||
assert!(status.success(), "git {:?} failed", args);
|
||||
}
|
||||
|
||||
fn init_git_repo(root: &Path) {
|
||||
run_git(root, &["init", "-q"]);
|
||||
run_git(root, &["config", "user.email", "test@example.com"]);
|
||||
run_git(root, &["config", "user.name", "Test User"]);
|
||||
}
|
||||
|
||||
fn commit_all(root: &Path, message: &str) {
|
||||
run_git(root, &["add", "."]);
|
||||
run_git(root, &["commit", "-q", "-m", message]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_log_lists_recent_commits() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
init_git_repo(tmp.path());
|
||||
fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
|
||||
commit_all(tmp.path(), "first");
|
||||
fs::write(tmp.path().join("file.txt"), "two\n").expect("write");
|
||||
commit_all(tmp.path(), "second");
|
||||
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let result = GitLogTool
|
||||
.execute(json!({ "max_count": 1 }), &ctx)
|
||||
.await
|
||||
.expect("execute");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("Subject: second"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_show_returns_patch_for_revision() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
init_git_repo(tmp.path());
|
||||
fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
|
||||
commit_all(tmp.path(), "first");
|
||||
fs::write(tmp.path().join("file.txt"), "one\ntwo\n").expect("write");
|
||||
commit_all(tmp.path(), "second");
|
||||
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let result = GitShowTool
|
||||
.execute(json!({ "rev": "HEAD", "stat": false }), &ctx)
|
||||
.await
|
||||
.expect("execute");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("diff --git"));
|
||||
assert!(result.content.contains("+two"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_blame_reports_author_for_range() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
init_git_repo(tmp.path());
|
||||
let src = tmp.path().join("src");
|
||||
fs::create_dir_all(&src).expect("mkdir");
|
||||
let file = src.join("lib.rs");
|
||||
fs::write(&file, "pub fn one() -> i32 { 1 }\n").expect("write");
|
||||
commit_all(tmp.path(), "first");
|
||||
fs::write(&file, "pub fn one() -> i32 { 2 }\n").expect("write");
|
||||
commit_all(tmp.path(), "second");
|
||||
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let result = GitBlameTool
|
||||
.execute(
|
||||
json!({
|
||||
"path": "src/lib.rs",
|
||||
"start_line": 1,
|
||||
"max_lines": 1
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("execute");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("Test User"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn git_blame_errors_for_non_file_path() {
|
||||
if !git_available() {
|
||||
return;
|
||||
}
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let result = GitBlameTool
|
||||
.execute(json!({ "path": "." }), &ctx)
|
||||
.await
|
||||
.expect_err("directory path should fail");
|
||||
assert!(matches!(result, ToolError::InvalidInput { .. }));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod diagnostics;
|
||||
pub mod file;
|
||||
pub mod file_search;
|
||||
pub mod git;
|
||||
pub mod git_history;
|
||||
pub mod parallel;
|
||||
pub mod plan;
|
||||
pub mod project;
|
||||
@@ -19,6 +20,7 @@ pub mod swarm;
|
||||
pub mod test_runner;
|
||||
pub mod todo;
|
||||
pub mod user_input;
|
||||
pub mod validate_data;
|
||||
pub mod web_run;
|
||||
pub mod web_search;
|
||||
|
||||
|
||||
+19
-1
@@ -305,6 +305,15 @@ impl ToolRegistryBuilder {
|
||||
.with_tool(Arc::new(GitDiffTool))
|
||||
}
|
||||
|
||||
/// Include git history tools (`git_log`, `git_show`, `git_blame`).
|
||||
#[must_use]
|
||||
pub fn with_git_history_tools(self) -> Self {
|
||||
use super::git_history::{GitBlameTool, GitLogTool, GitShowTool};
|
||||
self.with_tool(Arc::new(GitLogTool))
|
||||
.with_tool(Arc::new(GitShowTool))
|
||||
.with_tool(Arc::new(GitBlameTool))
|
||||
}
|
||||
|
||||
/// Include workspace diagnostics tool.
|
||||
#[must_use]
|
||||
pub fn with_diagnostics_tool(self) -> Self {
|
||||
@@ -326,6 +335,13 @@ impl ToolRegistryBuilder {
|
||||
self.with_tool(Arc::new(RunTestsTool))
|
||||
}
|
||||
|
||||
/// Include structured data validation tool (`validate_data`).
|
||||
#[must_use]
|
||||
pub fn with_validation_tools(self) -> Self {
|
||||
use super::validate_data::ValidateDataTool;
|
||||
self.with_tool(Arc::new(ValidateDataTool))
|
||||
}
|
||||
|
||||
/// Include web search tools.
|
||||
#[must_use]
|
||||
pub fn with_web_tools(self) -> Self {
|
||||
@@ -382,9 +398,11 @@ impl ToolRegistryBuilder {
|
||||
.with_parallel_tool()
|
||||
.with_patch_tools()
|
||||
.with_git_tools()
|
||||
.with_git_history_tools()
|
||||
.with_diagnostics_tool()
|
||||
.with_project_tools()
|
||||
.with_test_runner_tool();
|
||||
.with_test_runner_tool()
|
||||
.with_validation_tools();
|
||||
|
||||
if allow_shell {
|
||||
builder.with_shell_tools()
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! Structured data validation tool: `validate_data`.
|
||||
//!
|
||||
//! Validates JSON or TOML from inline content or a workspace file path and
|
||||
//! returns parser errors with lightweight metadata.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_str,
|
||||
};
|
||||
|
||||
/// Tool for validating JSON/TOML configuration data.
|
||||
pub struct ValidateDataTool;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DataFormat {
|
||||
Auto,
|
||||
Json,
|
||||
Toml,
|
||||
}
|
||||
|
||||
impl DataFormat {
|
||||
fn from_input(raw: Option<&str>) -> Result<Self, ToolError> {
|
||||
let format = raw.unwrap_or("auto");
|
||||
match format {
|
||||
"auto" => Ok(Self::Auto),
|
||||
"json" => Ok(Self::Json),
|
||||
"toml" => Ok(Self::Toml),
|
||||
_ => Err(ToolError::invalid_input(format!(
|
||||
"Unsupported format '{format}'. Expected one of: auto, json, toml"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Auto => "auto",
|
||||
Self::Json => "json",
|
||||
Self::Toml => "toml",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for ValidateDataTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"validate_data"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Validate JSON or TOML content from inline input or a workspace file."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Optional path to a file within the workspace."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Optional inline content to validate."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "json", "toml"],
|
||||
"default": "auto",
|
||||
"description": "Validation format. 'auto' infers from extension then falls back to trying both."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let path = optional_str(&input, "path");
|
||||
let content = optional_str(&input, "content");
|
||||
let requested_format = DataFormat::from_input(optional_str(&input, "format"))?;
|
||||
|
||||
let (source_name, raw_content, extension) = load_input_source(path, content, context)?;
|
||||
match requested_format {
|
||||
DataFormat::Json => validate_json(&raw_content, &source_name),
|
||||
DataFormat::Toml => validate_toml(&raw_content, &source_name),
|
||||
DataFormat::Auto => validate_auto(&raw_content, &source_name, extension.as_deref()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_input_source(
|
||||
path: Option<&str>,
|
||||
content: Option<&str>,
|
||||
context: &ToolContext,
|
||||
) -> Result<(String, String, Option<String>), ToolError> {
|
||||
match (path, content) {
|
||||
(Some(_), Some(_)) => Err(ToolError::invalid_input(
|
||||
"Provide either 'path' or 'content', but not both.",
|
||||
)),
|
||||
(None, None) => Err(ToolError::missing_field("path or content")),
|
||||
(Some(path), None) => {
|
||||
let resolved = context.resolve_path(path)?;
|
||||
let raw_content = fs::read_to_string(&resolved).map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to read {}: {e}", resolved.display()))
|
||||
})?;
|
||||
let extension = resolved
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
Ok((path.to_string(), raw_content, extension))
|
||||
}
|
||||
(None, Some(content)) => Ok(("inline".to_string(), content.to_string(), None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_auto(
|
||||
raw_content: &str,
|
||||
source_name: &str,
|
||||
extension: Option<&str>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let hint = match extension {
|
||||
Some("json") => Some(DataFormat::Json),
|
||||
Some("toml") => Some(DataFormat::Toml),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(format_hint) = hint {
|
||||
return match format_hint {
|
||||
DataFormat::Json => validate_json(raw_content, source_name),
|
||||
DataFormat::Toml => validate_toml(raw_content, source_name),
|
||||
DataFormat::Auto => unreachable!(),
|
||||
};
|
||||
}
|
||||
|
||||
let json_result = serde_json::from_str::<serde_json::Value>(raw_content);
|
||||
if let Ok(parsed) = &json_result {
|
||||
return build_success_result(DataFormat::Json, source_name, summarize_json(parsed));
|
||||
}
|
||||
|
||||
let toml_result = toml::from_str::<toml::Value>(raw_content);
|
||||
if let Ok(parsed) = &toml_result {
|
||||
return build_success_result(DataFormat::Toml, source_name, summarize_toml(parsed));
|
||||
}
|
||||
|
||||
let json_error = json_result.err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let toml_error = toml_result.err().map(|e| e.to_string()).unwrap_or_default();
|
||||
|
||||
Ok(
|
||||
ToolResult::error(
|
||||
"Validation failed in auto mode: content is neither valid JSON nor TOML.",
|
||||
)
|
||||
.with_metadata(json!({
|
||||
"valid": false,
|
||||
"format": DataFormat::Auto.as_str(),
|
||||
"source": source_name,
|
||||
"json_error": json_error,
|
||||
"toml_error": toml_error,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_json(raw_content: &str, source_name: &str) -> Result<ToolResult, ToolError> {
|
||||
match serde_json::from_str::<serde_json::Value>(raw_content) {
|
||||
Ok(parsed) => build_success_result(DataFormat::Json, source_name, summarize_json(&parsed)),
|
||||
Err(err) => Ok(
|
||||
ToolResult::error(format!("Invalid JSON: {err}")).with_metadata(json!({
|
||||
"valid": false,
|
||||
"format": DataFormat::Json.as_str(),
|
||||
"source": source_name,
|
||||
"error": err.to_string(),
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_toml(raw_content: &str, source_name: &str) -> Result<ToolResult, ToolError> {
|
||||
match toml::from_str::<toml::Value>(raw_content) {
|
||||
Ok(parsed) => build_success_result(DataFormat::Toml, source_name, summarize_toml(&parsed)),
|
||||
Err(err) => Ok(
|
||||
ToolResult::error(format!("Invalid TOML: {err}")).with_metadata(json!({
|
||||
"valid": false,
|
||||
"format": DataFormat::Toml.as_str(),
|
||||
"source": source_name,
|
||||
"error": err.to_string(),
|
||||
})),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_success_result(
|
||||
format: DataFormat,
|
||||
source_name: &str,
|
||||
summary: Value,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
ToolResult::json(&json!({
|
||||
"valid": true,
|
||||
"format": format.as_str(),
|
||||
"source": source_name,
|
||||
"summary": summary,
|
||||
}))
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
|
||||
fn summarize_json(value: &serde_json::Value) -> Value {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => json!({
|
||||
"top_level": "object",
|
||||
"entries": map.len(),
|
||||
"keys_preview": map.keys().take(10).collect::<Vec<_>>(),
|
||||
}),
|
||||
serde_json::Value::Array(arr) => json!({
|
||||
"top_level": "array",
|
||||
"entries": arr.len(),
|
||||
}),
|
||||
serde_json::Value::String(_) => json!({ "top_level": "string" }),
|
||||
serde_json::Value::Number(_) => json!({ "top_level": "number" }),
|
||||
serde_json::Value::Bool(_) => json!({ "top_level": "boolean" }),
|
||||
serde_json::Value::Null => json!({ "top_level": "null" }),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_toml(value: &toml::Value) -> Value {
|
||||
match value {
|
||||
toml::Value::Table(table) => json!({
|
||||
"top_level": "table",
|
||||
"entries": table.len(),
|
||||
"keys_preview": table.keys().take(10).collect::<Vec<_>>(),
|
||||
}),
|
||||
toml::Value::Array(arr) => json!({
|
||||
"top_level": "array",
|
||||
"entries": arr.len(),
|
||||
}),
|
||||
toml::Value::String(_) => json!({ "top_level": "string" }),
|
||||
toml::Value::Integer(_) => json!({ "top_level": "integer" }),
|
||||
toml::Value::Float(_) => json!({ "top_level": "float" }),
|
||||
toml::Value::Boolean(_) => json!({ "top_level": "boolean" }),
|
||||
toml::Value::Datetime(_) => json!({ "top_level": "datetime" }),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_json_content_succeeds() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
|
||||
let result = ValidateDataTool
|
||||
.execute(
|
||||
json!({"content": "{\"name\":\"deepseek\"}", "format": "json"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("execute");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("\"valid\": true"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_toml_file_succeeds() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
let config = tmp.path().join("config.toml");
|
||||
fs::write(&config, "name = \"deepseek\"\n").expect("write");
|
||||
|
||||
let result = ValidateDataTool
|
||||
.execute(json!({"path": "config.toml", "format": "toml"}), &ctx)
|
||||
.await
|
||||
.expect("execute");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("\"format\": \"toml\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_auto_reports_error_for_invalid_content() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
|
||||
let result = ValidateDataTool
|
||||
.execute(json!({"content": "not-valid-data"}), &ctx)
|
||||
.await
|
||||
.expect("execute");
|
||||
assert!(!result.success);
|
||||
assert!(result.content.contains("Validation failed in auto mode"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_rejects_path_and_content_together() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path());
|
||||
|
||||
let err = ValidateDataTool
|
||||
.execute(json!({"path": "a.toml", "content": "x=1"}), &ctx)
|
||||
.await
|
||||
.expect_err("should fail");
|
||||
assert!(matches!(err, ToolError::InvalidInput { .. }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user