返回 CodeWhale
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 if context.features.enabled(crate::features::Feature::DataHost) {
99 return validate_host(
100 &raw_content,
101 &source_name,
102 requested_format,
103 extension.as_deref(),
104 context,
105 )
106 .await;
107 }
108 match requested_format {
109 DataFormat::Json => validate_json(&raw_content, &source_name),
110 DataFormat::Toml => validate_toml(&raw_content, &source_name),
111 DataFormat::Auto => validate_auto(&raw_content, &source_name, extension.as_deref()),
112 }
113 }
114 }
115
116 fn load_input_source(
117 path: Option<&str>,
118 content: Option<&str>,
119 context: &ToolContext,
120 ) -> Result<(String, String, Option<String>), ToolError> {
121 match (path, content) {
122 (Some(_), Some(_)) => Err(ToolError::invalid_input(
123 "Provide either 'path' or 'content', but not both.",
124 )),
125 (None, None) => Err(ToolError::missing_field("path or content")),
126 (Some(path), None) => {
127 let resolved = context.resolve_path(path)?;
128 let raw_content = if context.features.enabled(crate::features::Feature::DataHost) {
129 use std::io::Read;
130 let mut content = String::new();
131 fs::File::open(&resolved)
132 .and_then(|file| file.take(1024 * 1024 + 1).read_to_string(&mut content))
133 .map_err(|e| {
134 ToolError::execution_failed(format!(
135 "Failed to read {}: {e}",
136 resolved.display()
137 ))
138 })?;
139 if content.len() > 1024 * 1024 {
140 return Err(ToolError::invalid_input("Host data input exceeds 1 MiB"));
141 }
142 content
143 } else {
144 fs::read_to_string(&resolved).map_err(|e| {
145 ToolError::execution_failed(format!(
146 "Failed to read {}: {e}",
147 resolved.display()
148 ))
149 })?
150 };
151 let extension = resolved
152 .extension()
153 .and_then(|ext| ext.to_str())
154 .map(|s| s.to_ascii_lowercase());
155 Ok((path.to_string(), raw_content, extension))
156 }
157 (None, Some(content)) => Ok(("inline".to_string(), content.to_string(), None)),
158 }
159 }
160
161 /// Preserve the existing Rust parsers and their exact diagnostics. The host
162 /// selects the format and owns summary/result presentation from minimal facts.
163 async fn validate_host(
164 raw: &str,
165 source: &str,
166 format: DataFormat,
167 extension: Option<&str>,
168 context: &ToolContext,
169 ) -> Result<ToolResult, ToolError> {
170 if raw.len() > 1024 * 1024 {
171 return Err(ToolError::invalid_input("Host data input exceeds 1 MiB"));
172 }
173 let json = match serde_json::from_str::<Value>(raw) {
174 Ok(parsed) => json!({"ok":true,"value":json_descriptor(&parsed)}),
175 Err(error) => json!({"ok":false,"error":error.to_string()}),
176 };
177 let toml = match toml::from_str::<toml::Value>(raw) {
178 Ok(parsed) => json!({"ok":true,"value":toml_descriptor(&parsed)}),
179 Err(error) => json!({"ok":false,"error":error.to_string()}),
180 };
181 let result = crate::extension_host::manager().execute_stock(
182 crate::extension_host::StockOperation::ValidateData,
183 json!({"format":format.as_str(),"source":source,"extension":extension,"json":json,"toml":toml}),
184 context,
185 std::time::Duration::from_secs(120),
186 ).await?;
187 let metadata = result
188 .metadata
189 .as_ref()
190 .ok_or_else(|| ToolError::execution_failed("Host data result omitted metadata"))?;
191 if metadata.get("valid").and_then(Value::as_bool) != Some(result.success)
192 || metadata.get("source").and_then(Value::as_str) != Some(source)
193 {
194 return Err(ToolError::execution_failed(
195 "Host data result changed its captured source",
196 ));
197 }
198 if result.success {
199 if metadata.get("summary").is_none() {
200 return Err(ToolError::execution_failed(
201 "Host data result changed its captured source",
202 ));
203 }
204 ToolResult::json(metadata).map_err(|error| ToolError::execution_failed(error.to_string()))
205 } else {
206 Ok(result)
207 }
208 }
209 fn json_descriptor(value: &Value) -> Value {
210 match value {
211 Value::Object(map) => json!({"kind":"object","keys":map.keys().collect::<Vec<_>>()}),
212 Value::Array(array) => json!({"kind":"array","entries":array.len()}),
213 Value::String(_) => json!({"kind":"string"}),
214 Value::Number(_) => json!({"kind":"number"}),
215 Value::Bool(_) => json!({"kind":"boolean"}),
216 Value::Null => json!({"kind":"null"}),
217 }
218 }
219 fn toml_descriptor(value: &toml::Value) -> Value {
220 match value {
221 toml::Value::Table(table) => {
222 json!({"kind":"table","keys":table.keys().collect::<Vec<_>>()})
223 }
224 toml::Value::Array(array) => json!({"kind":"array","entries":array.len()}),
225 toml::Value::String(_) => json!({"kind":"string"}),
226 toml::Value::Integer(_) => json!({"kind":"integer"}),
227 toml::Value::Float(_) => json!({"kind":"float"}),
228 toml::Value::Boolean(_) => json!({"kind":"boolean"}),
229 toml::Value::Datetime(_) => json!({"kind":"datetime"}),
230 }
231 }
232
233 fn validate_auto(
234 raw_content: &str,
235 source_name: &str,
236 extension: Option<&str>,
237 ) -> Result<ToolResult, ToolError> {
238 let hint = match extension {
239 Some("json") => Some(DataFormat::Json),
240 Some("toml") => Some(DataFormat::Toml),
241 _ => None,
242 };
243
244 if let Some(format_hint) = hint {
245 return match format_hint {
246 DataFormat::Json => validate_json(raw_content, source_name),
247 DataFormat::Toml => validate_toml(raw_content, source_name),
248 DataFormat::Auto => unreachable!(),
249 };
250 }
251
252 let json_result = serde_json::from_str::<serde_json::Value>(raw_content);
253 if let Ok(parsed) = &json_result {
254 return build_success_result(DataFormat::Json, source_name, summarize_json(parsed));
255 }
256
257 let toml_result = toml::from_str::<toml::Value>(raw_content);
258 if let Ok(parsed) = &toml_result {
259 return build_success_result(DataFormat::Toml, source_name, summarize_toml(parsed));
260 }
261
262 let json_error = json_result.err().map(|e| e.to_string()).unwrap_or_default();
263 let toml_error = toml_result.err().map(|e| e.to_string()).unwrap_or_default();
264
265 Ok(
266 ToolResult::error(
267 "Validation failed in auto mode: content is neither valid JSON nor TOML.",
268 )
269 .with_metadata(json!({
270 "valid": false,
271 "format": DataFormat::Auto.as_str(),
272 "source": source_name,
273 "json_error": json_error,
274 "toml_error": toml_error,
275 })),
276 )
277 }
278
279 fn validate_json(raw_content: &str, source_name: &str) -> Result<ToolResult, ToolError> {
280 match serde_json::from_str::<serde_json::Value>(raw_content) {
281 Ok(parsed) => build_success_result(DataFormat::Json, source_name, summarize_json(&parsed)),
282 Err(err) => Ok(
283 ToolResult::error(format!("Invalid JSON: {err}")).with_metadata(json!({
284 "valid": false,
285 "format": DataFormat::Json.as_str(),
286 "source": source_name,
287 "error": err.to_string(),
288 })),
289 ),
290 }
291 }
292
293 fn validate_toml(raw_content: &str, source_name: &str) -> Result<ToolResult, ToolError> {
294 match toml::from_str::<toml::Value>(raw_content) {
295 Ok(parsed) => build_success_result(DataFormat::Toml, source_name, summarize_toml(&parsed)),
296 Err(err) => Ok(
297 ToolResult::error(format!("Invalid TOML: {err}")).with_metadata(json!({
298 "valid": false,
299 "format": DataFormat::Toml.as_str(),
300 "source": source_name,
301 "error": err.to_string(),
302 })),
303 ),
304 }
305 }
306
307 fn build_success_result(
308 format: DataFormat,
309 source_name: &str,
310 summary: Value,
311 ) -> Result<ToolResult, ToolError> {
312 ToolResult::json(&json!({
313 "valid": true,
314 "format": format.as_str(),
315 "source": source_name,
316 "summary": summary,
317 }))
318 .map_err(|e| ToolError::execution_failed(e.to_string()))
319 }
320
321 fn summarize_json(value: &serde_json::Value) -> Value {
322 match value {
323 serde_json::Value::Object(map) => json!({
324 "top_level": "object",
325 "entries": map.len(),
326 "keys_preview": map.keys().take(10).collect::<Vec<_>>(),
327 }),
328 serde_json::Value::Array(arr) => json!({
329 "top_level": "array",
330 "entries": arr.len(),
331 }),
332 serde_json::Value::String(_) => json!({ "top_level": "string" }),
333 serde_json::Value::Number(_) => json!({ "top_level": "number" }),
334 serde_json::Value::Bool(_) => json!({ "top_level": "boolean" }),
335 serde_json::Value::Null => json!({ "top_level": "null" }),
336 }
337 }
338
339 fn summarize_toml(value: &toml::Value) -> Value {
340 match value {
341 toml::Value::Table(table) => json!({
342 "top_level": "table",
343 "entries": table.len(),
344 "keys_preview": table.keys().take(10).collect::<Vec<_>>(),
345 }),
346 toml::Value::Array(arr) => json!({
347 "top_level": "array",
348 "entries": arr.len(),
349 }),
350 toml::Value::String(_) => json!({ "top_level": "string" }),
351 toml::Value::Integer(_) => json!({ "top_level": "integer" }),
352 toml::Value::Float(_) => json!({ "top_level": "float" }),
353 toml::Value::Boolean(_) => json!({ "top_level": "boolean" }),
354 toml::Value::Datetime(_) => json!({ "top_level": "datetime" }),
355 }
356 }
357
358 #[cfg(test)]
359 mod tests {
360 use super::*;
361 use tempfile::tempdir;
362
363 #[tokio::test]
364 async fn validate_json_content_succeeds() {
365 let tmp = tempdir().expect("tempdir");
366 let ctx = ToolContext::new(tmp.path());
367
368 let result = ValidateDataTool
369 .execute(
370 json!({"content": "{\"name\":\"deepseek\"}", "format": "json"}),
371 &ctx,
372 )
373 .await
374 .expect("execute");
375 assert!(result.success);
376 let content: Value = serde_json::from_str(&result.content).expect("validation json");
377 assert_eq!(content.get("valid").and_then(Value::as_bool), Some(true));
378 }
379
380 #[tokio::test]
381 async fn validate_toml_file_succeeds() {
382 let tmp = tempdir().expect("tempdir");
383 let ctx = ToolContext::new(tmp.path());
384 let config = tmp.path().join("config.toml");
385 fs::write(&config, "name = \"deepseek\"\n").expect("write");
386
387 let result = ValidateDataTool
388 .execute(json!({"path": "config.toml", "format": "toml"}), &ctx)
389 .await
390 .expect("execute");
391 assert!(result.success);
392 let content: Value = serde_json::from_str(&result.content).expect("validation json");
393 assert_eq!(content.get("format").and_then(Value::as_str), Some("toml"));
394 }
395
396 #[tokio::test]
397 async fn validate_auto_reports_error_for_invalid_content() {
398 let tmp = tempdir().expect("tempdir");
399 let ctx = ToolContext::new(tmp.path());
400
401 let result = ValidateDataTool
402 .execute(json!({"content": "not-valid-data"}), &ctx)
403 .await
404 .expect("execute");
405 assert!(!result.success);
406 assert!(result.content.contains("Validation failed in auto mode"));
407 }
408
409 #[tokio::test]
410 async fn validate_rejects_path_and_content_together() {
411 let tmp = tempdir().expect("tempdir");
412 let ctx = ToolContext::new(tmp.path());
413
414 let err = ValidateDataTool
415 .execute(json!({"path": "a.toml", "content": "x=1"}), &ctx)
416 .await
417 .expect_err("should fail");
418 assert!(matches!(err, ToolError::InvalidInput { .. }));
419 }
420 }
421
421 lines RUST