From 6021fc56c82f1f4ea4672380bed4208aa7be1baf Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Mon, 2 Mar 2026 09:21:15 -0600 Subject: [PATCH] release: 0.3.27 add git_history and validate_data tools --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/core/engine.rs | 2 + src/tools/git_history.rs | 627 +++++++++++++++++++++++++++++++++++++ src/tools/mod.rs | 2 + src/tools/registry.rs | 20 +- src/tools/validate_data.rs | 316 +++++++++++++++++++ 7 files changed, 968 insertions(+), 3 deletions(-) create mode 100644 src/tools/git_history.rs create mode 100644 src/tools/validate_data.rs diff --git a/Cargo.lock b/Cargo.lock index 401683f9..9ced60b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -747,7 +747,7 @@ dependencies = [ [[package]] name = "deepseek-tui" -version = "0.3.26" +version = "0.3.27" dependencies = [ "anyhow", "arboard", diff --git a/Cargo.toml b/Cargo.toml index fb483438..546f584b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/core/engine.rs b/src/core/engine.rs index f260f022..909898db 100644 --- a/src/core/engine.rs +++ b/src/core/engine.rs @@ -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 { diff --git a/src/tools/git_history.rs b/src/tools/git_history.rs new file mode 100644 index 00000000..1be5a2b8 --- /dev/null +++ b/src/tools/git_history.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +} + +fn resolve_git_context(context: &ToolContext, path: Option<&str>) -> Result { + 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 { + 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::>() + .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 { .. })); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 2dcaa993..09daa688 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -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; diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 98a038b8..8edade69 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -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() diff --git a/src/tools/validate_data.rs b/src/tools/validate_data.rs new file mode 100644 index 00000000..991360ef --- /dev/null +++ b/src/tools/validate_data.rs @@ -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 { + 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 { + 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 { + 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), 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 { + 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::(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::(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 { + match serde_json::from_str::(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 { + match toml::from_str::(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::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::>(), + }), + 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::>(), + }), + 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 { .. })); + } +}