返回 DeepSeek-TUI-2026
validate_data.rs
根目录 / crates / tui / src / tools / validate_data.rs
1 //! Structured data validation tool: `validate_data`.
2 //!
3 //! Validates JSON or TOML from inline content or a workspace file path and
4 //! returns parser errors with lightweight metadata.
5
6 use std::fs;
7
8 use async_trait::async_trait;
9 use serde_json::{Value, json};
10
11 use super::spec::{
12 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_str,
13 };
14
15 /// Tool for validating JSON/TOML configuration data.
16 pub struct ValidateDataTool;
17
18 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
19 enum DataFormat {
20 Auto,
21 Json,
22 Toml,
23 }
24
25 impl DataFormat {
26 fn from_input(raw: Option<&str>) -> Result<Self, ToolError> {
27 let format = raw.unwrap_or("auto");
28 match format {
29 "auto" => Ok(Self::Auto),
30 "json" => Ok(Self::Json),
31 "toml" => Ok(Self::Toml),
32 _ => Err(ToolError::invalid_input(format!(
33 "Unsupported format '{format}'. Expected one of: auto, json, toml"
34 ))),
35 }
36 }
37
38 fn as_str(self) -> &'static str {
39 match self {
40 Self::Auto => "auto",
41 Self::Json => "json",
42 Self::Toml => "toml",
43 }
44 }
45 }
46
47 #[async_trait]
48 impl ToolSpec for ValidateDataTool {
49 fn name(&self) -> &'static str {
50 "validate_data"
51 }
52
53 fn description(&self) -> &'static str {
54 "Validate JSON or TOML content from inline input or a workspace file."
55 }
56
57 fn input_schema(&self) -> Value {
58 json!({
59 "type": "object",
60 "properties": {
61 "path": {
62 "type": "string",
63 "description": "Optional path to a file within the workspace."
64 },
65 "content": {
66 "type": "string",
67 "description": "Optional inline content to validate."
68 },
69 "format": {
70 "type": "string",
71 "enum": ["auto", "json", "toml"],
72 "default": "auto",
73 "description": "Validation format. 'auto' infers from extension then falls back to trying both."
74 }
75 },
76 "additionalProperties": false
77 })
78 }
79
80 fn capabilities(&self) -> Vec<ToolCapability> {
81 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
82 }
83
84 fn approval_requirement(&self) -> ApprovalRequirement {
85 ApprovalRequirement::Auto
86 }
87
88 fn supports_parallel(&self) -> bool {
89 true
90 }
91
92 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
93 let path = optional_str(&input, "path");
94 let content = optional_str(&input, "content");
95 let requested_format = DataFormat::from_input(optional_str(&input, "format"))?;
96
97 let (source_name, raw_content, extension) = load_input_source(path, content, context)?;
98 match requested_format {
99 DataFormat::Json => validate_json(&raw_content, &source_name),
100 DataFormat::Toml => validate_toml(&raw_content, &source_name),
101 DataFormat::Auto => validate_auto(&raw_content, &source_name, extension.as_deref()),
102 }
103 }
104 }
105
106 fn load_input_source(
107 path: Option<&str>,
108 content: Option<&str>,
109 context: &ToolContext,
110 ) -> Result<(String, String, Option<String>), ToolError> {
111 match (path, content) {
112 (Some(_), Some(_)) => Err(ToolError::invalid_input(
113 "Provide either 'path' or 'content', but not both.",
114 )),
115 (None, None) => Err(ToolError::missing_field("path or content")),
116 (Some(path), None) => {
117 let resolved = context.resolve_path(path)?;
118 let raw_content = fs::read_to_string(&resolved).map_err(|e| {
119 ToolError::execution_failed(format!("Failed to read {}: {e}", resolved.display()))
120 })?;
121 let extension = resolved
122 .extension()
123 .and_then(|ext| ext.to_str())
124 .map(|s| s.to_ascii_lowercase());
125 Ok((path.to_string(), raw_content, extension))
126 }
127 (None, Some(content)) => Ok(("inline".to_string(), content.to_string(), None)),
128 }
129 }
130
131 fn validate_auto(
132 raw_content: &str,
133 source_name: &str,
134 extension: Option<&str>,
135 ) -> Result<ToolResult, ToolError> {
136 let hint = match extension {
137 Some("json") => Some(DataFormat::Json),
138 Some("toml") => Some(DataFormat::Toml),
139 _ => None,
140 };
141
142 if let Some(format_hint) = hint {
143 return match format_hint {
144 DataFormat::Json => validate_json(raw_content, source_name),
145 DataFormat::Toml => validate_toml(raw_content, source_name),
146 DataFormat::Auto => unreachable!(),
147 };
148 }
149
150 let json_result = serde_json::from_str::<serde_json::Value>(raw_content);
151 if let Ok(parsed) = &json_result {
152 return build_success_result(DataFormat::Json, source_name, summarize_json(parsed));
153 }
154
155 let toml_result = toml::from_str::<toml::Value>(raw_content);
156 if let Ok(parsed) = &toml_result {
157 return build_success_result(DataFormat::Toml, source_name, summarize_toml(parsed));
158 }
159
160 let json_error = json_result.err().map(|e| e.to_string()).unwrap_or_default();
161 let toml_error = toml_result.err().map(|e| e.to_string()).unwrap_or_default();
162
163 Ok(
164 ToolResult::error(
165 "Validation failed in auto mode: content is neither valid JSON nor TOML.",
166 )
167 .with_metadata(json!({
168 "valid": false,
169 "format": DataFormat::Auto.as_str(),
170 "source": source_name,
171 "json_error": json_error,
172 "toml_error": toml_error,
173 })),
174 )
175 }
176
177 fn validate_json(raw_content: &str, source_name: &str) -> Result<ToolResult, ToolError> {
178 match serde_json::from_str::<serde_json::Value>(raw_content) {
179 Ok(parsed) => build_success_result(DataFormat::Json, source_name, summarize_json(&parsed)),
180 Err(err) => Ok(
181 ToolResult::error(format!("Invalid JSON: {err}")).with_metadata(json!({
182 "valid": false,
183 "format": DataFormat::Json.as_str(),
184 "source": source_name,
185 "error": err.to_string(),
186 })),
187 ),
188 }
189 }
190
191 fn validate_toml(raw_content: &str, source_name: &str) -> Result<ToolResult, ToolError> {
192 match toml::from_str::<toml::Value>(raw_content) {
193 Ok(parsed) => build_success_result(DataFormat::Toml, source_name, summarize_toml(&parsed)),
194 Err(err) => Ok(
195 ToolResult::error(format!("Invalid TOML: {err}")).with_metadata(json!({
196 "valid": false,
197 "format": DataFormat::Toml.as_str(),
198 "source": source_name,
199 "error": err.to_string(),
200 })),
201 ),
202 }
203 }
204
205 fn build_success_result(
206 format: DataFormat,
207 source_name: &str,
208 summary: Value,
209 ) -> Result<ToolResult, ToolError> {
210 ToolResult::json(&json!({
211 "valid": true,
212 "format": format.as_str(),
213 "source": source_name,
214 "summary": summary,
215 }))
216 .map_err(|e| ToolError::execution_failed(e.to_string()))
217 }
218
219 fn summarize_json(value: &serde_json::Value) -> Value {
220 match value {
221 serde_json::Value::Object(map) => json!({
222 "top_level": "object",
223 "entries": map.len(),
224 "keys_preview": map.keys().take(10).collect::<Vec<_>>(),
225 }),
226 serde_json::Value::Array(arr) => json!({
227 "top_level": "array",
228 "entries": arr.len(),
229 }),
230 serde_json::Value::String(_) => json!({ "top_level": "string" }),
231 serde_json::Value::Number(_) => json!({ "top_level": "number" }),
232 serde_json::Value::Bool(_) => json!({ "top_level": "boolean" }),
233 serde_json::Value::Null => json!({ "top_level": "null" }),
234 }
235 }
236
237 fn summarize_toml(value: &toml::Value) -> Value {
238 match value {
239 toml::Value::Table(table) => json!({
240 "top_level": "table",
241 "entries": table.len(),
242 "keys_preview": table.keys().take(10).collect::<Vec<_>>(),
243 }),
244 toml::Value::Array(arr) => json!({
245 "top_level": "array",
246 "entries": arr.len(),
247 }),
248 toml::Value::String(_) => json!({ "top_level": "string" }),
249 toml::Value::Integer(_) => json!({ "top_level": "integer" }),
250 toml::Value::Float(_) => json!({ "top_level": "float" }),
251 toml::Value::Boolean(_) => json!({ "top_level": "boolean" }),
252 toml::Value::Datetime(_) => json!({ "top_level": "datetime" }),
253 }
254 }
255
256 #[cfg(test)]
257 mod tests {
258 use super::*;
259 use tempfile::tempdir;
260
261 #[tokio::test]
262 async fn validate_json_content_succeeds() {
263 let tmp = tempdir().expect("tempdir");
264 let ctx = ToolContext::new(tmp.path());
265
266 let result = ValidateDataTool
267 .execute(
268 json!({"content": "{\"name\":\"deepseek\"}", "format": "json"}),
269 &ctx,
270 )
271 .await
272 .expect("execute");
273 assert!(result.success);
274 assert!(result.content.contains("\"valid\": true"));
275 }
276
277 #[tokio::test]
278 async fn validate_toml_file_succeeds() {
279 let tmp = tempdir().expect("tempdir");
280 let ctx = ToolContext::new(tmp.path());
281 let config = tmp.path().join("config.toml");
282 fs::write(&config, "name = \"deepseek\"\n").expect("write");
283
284 let result = ValidateDataTool
285 .execute(json!({"path": "config.toml", "format": "toml"}), &ctx)
286 .await
287 .expect("execute");
288 assert!(result.success);
289 assert!(result.content.contains("\"format\": \"toml\""));
290 }
291
292 #[tokio::test]
293 async fn validate_auto_reports_error_for_invalid_content() {
294 let tmp = tempdir().expect("tempdir");
295 let ctx = ToolContext::new(tmp.path());
296
297 let result = ValidateDataTool
298 .execute(json!({"content": "not-valid-data"}), &ctx)
299 .await
300 .expect("execute");
301 assert!(!result.success);
302 assert!(result.content.contains("Validation failed in auto mode"));
303 }
304
305 #[tokio::test]
306 async fn validate_rejects_path_and_content_together() {
307 let tmp = tempdir().expect("tempdir");
308 let ctx = ToolContext::new(tmp.path());
309
310 let err = ValidateDataTool
311 .execute(json!({"path": "a.toml", "content": "x=1"}), &ctx)
312 .await
313 .expect_err("should fail");
314 assert!(matches!(err, ToolError::InvalidInput { .. }));
315 }
316 }
317
317 lines RUST