317 lines
10 KiB
Rust
317 lines
10 KiB
Rust
//! 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 { .. }));
|
|
}
|
|
}
|