1use std::fs;
10
11use anyhow::Result;
12use fraiseql_core::schema::{CompiledSchema, SchemaDependencyGraph};
13use serde::Serialize;
14
15use crate::output::CommandResult;
16
17#[derive(Debug, Clone, Default)]
19pub struct ValidateOptions {
20 pub check_cycles: bool,
22
23 pub check_unused: bool,
25
26 pub strict: bool,
28
29 pub filter_types: Vec<String>,
31}
32
33#[derive(Debug, Serialize)]
35pub struct ValidationResult {
36 pub schema_path: String,
38
39 pub valid: bool,
41
42 pub type_count: usize,
44
45 pub query_count: usize,
47
48 pub mutation_count: usize,
50
51 #[serde(skip_serializing_if = "Vec::is_empty")]
53 pub cycles: Vec<CycleError>,
54
55 #[serde(skip_serializing_if = "Vec::is_empty")]
57 pub unused_types: Vec<String>,
58
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub type_analysis: Option<Vec<TypeAnalysis>>,
62}
63
64#[derive(Debug, Serialize)]
66pub struct CycleError {
67 pub types: Vec<String>,
69 pub path: String,
71}
72
73#[derive(Debug, Serialize)]
75pub struct TypeAnalysis {
76 pub name: String,
78 pub dependencies: Vec<String>,
80 pub dependents: Vec<String>,
82 pub transitive_dependencies: Vec<String>,
84}
85
86#[allow(dead_code)]
90pub async fn run(input: &str) -> Result<()> {
91 super::compile::run(input, None, None, Vec::new(), Vec::new(), Vec::new(), "unused", true, None)
93 .await
94}
95
96pub fn run_with_options(input: &str, opts: ValidateOptions) -> Result<CommandResult> {
98 let schema_content = fs::read_to_string(input)?;
100 let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
101
102 let graph = SchemaDependencyGraph::build(&schema);
104
105 let mut errors: Vec<String> = Vec::new();
106 let mut warnings: Vec<String> = Vec::new();
107 let mut cycles: Vec<CycleError> = Vec::new();
108 let mut unused_types: Vec<String> = Vec::new();
109
110 if opts.check_cycles {
112 let detected_cycles = graph.find_cycles();
113 for cycle in detected_cycles {
114 let cycle_error = CycleError {
115 types: cycle.nodes.clone(),
116 path: cycle.path_string(),
117 };
118 errors.push(format!("Circular dependency: {}", cycle.path_string()));
119 cycles.push(cycle_error);
120 }
121 }
122
123 if opts.check_unused {
125 let detected_unused = graph.find_unused();
126 for type_name in detected_unused {
127 if opts.strict {
128 errors.push(format!("Unused type: '{type_name}' has no incoming references"));
129 } else {
130 warnings.push(format!("Unused type: '{type_name}' has no incoming references"));
131 }
132 unused_types.push(type_name);
133 }
134 }
135
136 let type_analysis = if opts.filter_types.is_empty() {
138 None
139 } else {
140 let mut analyses = Vec::new();
141 for type_name in &opts.filter_types {
142 if graph.has_type(type_name) {
143 let deps = graph.dependencies_of(type_name);
144 let refs = graph.dependents_of(type_name);
145 let transitive = graph.transitive_dependencies(type_name);
146
147 analyses.push(TypeAnalysis {
148 name: type_name.clone(),
149 dependencies: deps,
150 dependents: refs,
151 transitive_dependencies: transitive.into_iter().collect(),
152 });
153 } else {
154 warnings.push(format!("Type '{type_name}' not found in schema"));
155 }
156 }
157 Some(analyses)
158 };
159
160 let result = ValidationResult {
162 schema_path: input.to_string(),
163 valid: errors.is_empty(),
164 type_count: schema.types.len(),
165 query_count: schema.queries.len(),
166 mutation_count: schema.mutations.len(),
167 cycles,
168 unused_types,
169 type_analysis,
170 };
171
172 let data = serde_json::to_value(&result)?;
173
174 if !errors.is_empty() {
175 Ok(CommandResult {
176 status: "validation-failed".to_string(),
177 command: "validate".to_string(),
178 data: Some(data),
179 message: Some(format!("{} validation error(s) found", errors.len())),
180 code: Some("VALIDATION_FAILED".to_string()),
181 errors,
182 warnings,
183 exit_code: 2,
184 })
185 } else if !warnings.is_empty() {
186 Ok(CommandResult::success_with_warnings("validate", data, warnings))
187 } else {
188 Ok(CommandResult::success("validate", data))
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use std::io::Write;
195
196 use tempfile::NamedTempFile;
197
198 use super::*;
199
200 fn create_valid_schema() -> String {
201 serde_json::json!({
202 "types": [
203 {
204 "name": "User",
205 "sql_source": "v_user",
206 "jsonb_column": "data",
207 "fields": [
208 {"name": "id", "field_type": "ID"},
209 {"name": "profile", "field_type": {"Object": "Profile"}, "nullable": true}
210 ],
211 "implements": []
212 },
213 {
214 "name": "Profile",
215 "sql_source": "v_profile",
216 "jsonb_column": "data",
217 "fields": [
218 {"name": "bio", "field_type": "String", "nullable": true}
219 ],
220 "implements": []
221 }
222 ],
223 "queries": [
224 {
225 "name": "users",
226 "sql_source": "v_user",
227 "return_type": "[User]",
228 "arguments": [],
229 "max_results": 1000
230 }
231 ],
232 "mutations": [],
233 "subscriptions": [],
234 "enums": [],
235 "input_types": [],
236 "interfaces": [],
237 "unions": [],
238 "directives": [],
239 "observers": []
240 })
241 .to_string()
242 }
243
244 fn create_schema_with_cycle() -> String {
245 serde_json::json!({
246 "types": [
247 {
248 "name": "A",
249 "sql_source": "v_a",
250 "jsonb_column": "data",
251 "fields": [
252 {"name": "id", "field_type": "ID"},
253 {"name": "b", "field_type": {"Object": "B"}}
254 ],
255 "implements": []
256 },
257 {
258 "name": "B",
259 "sql_source": "v_b",
260 "jsonb_column": "data",
261 "fields": [
262 {"name": "id", "field_type": "ID"},
263 {"name": "a", "field_type": {"Object": "A"}}
264 ],
265 "implements": []
266 }
267 ],
268 "queries": [
269 {
270 "name": "items",
271 "sql_source": "v_a",
272 "return_type": "[A]",
273 "arguments": [],
274 "max_results": 1000
275 }
276 ],
277 "mutations": [],
278 "subscriptions": [],
279 "enums": [],
280 "input_types": [],
281 "interfaces": [],
282 "unions": [],
283 "directives": [],
284 "observers": []
285 })
286 .to_string()
287 }
288
289 fn create_schema_with_unused() -> String {
290 serde_json::json!({
291 "types": [
292 {
293 "name": "User",
294 "sql_source": "v_user",
295 "jsonb_column": "data",
296 "fields": [
297 {"name": "id", "field_type": "ID"}
298 ],
299 "implements": []
300 },
301 {
302 "name": "OrphanType",
303 "sql_source": "v_orphan",
304 "jsonb_column": "data",
305 "fields": [
306 {"name": "data", "field_type": "String"}
307 ],
308 "implements": []
309 }
310 ],
311 "queries": [
312 {
313 "name": "users",
314 "sql_source": "v_user",
315 "return_type": "[User]",
316 "arguments": [],
317 "max_results": 1000
318 }
319 ],
320 "mutations": [],
321 "subscriptions": [],
322 "enums": [],
323 "input_types": [],
324 "interfaces": [],
325 "unions": [],
326 "directives": [],
327 "observers": []
328 })
329 .to_string()
330 }
331
332 #[test]
333 fn test_validate_valid_schema() {
334 let schema = create_valid_schema();
335 let mut temp_file = NamedTempFile::new().unwrap();
336 temp_file.write_all(schema.as_bytes()).unwrap();
337
338 let opts = ValidateOptions {
339 check_cycles: true,
340 check_unused: true,
341 strict: false,
342 filter_types: vec![],
343 };
344
345 let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
346
347 assert_eq!(result.status, "success");
348 }
349
350 #[test]
351 fn test_validate_detects_cycles() {
352 let schema = create_schema_with_cycle();
353 let mut temp_file = NamedTempFile::new().unwrap();
354 temp_file.write_all(schema.as_bytes()).unwrap();
355
356 let opts = ValidateOptions {
357 check_cycles: true,
358 check_unused: false,
359 strict: false,
360 filter_types: vec![],
361 };
362
363 let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
364
365 assert_eq!(result.status, "validation-failed");
366 assert!(result.errors.iter().any(|e| e.contains("Circular")));
367 }
368
369 #[test]
370 fn test_validate_cycles_disabled() {
371 let schema = create_schema_with_cycle();
372 let mut temp_file = NamedTempFile::new().unwrap();
373 temp_file.write_all(schema.as_bytes()).unwrap();
374
375 let opts = ValidateOptions {
376 check_cycles: false,
377 check_unused: false,
378 strict: false,
379 filter_types: vec![],
380 };
381
382 let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
383
384 assert_eq!(result.status, "success");
386 }
387
388 #[test]
389 fn test_validate_unused_as_warning() {
390 let schema = create_schema_with_unused();
391 let mut temp_file = NamedTempFile::new().unwrap();
392 temp_file.write_all(schema.as_bytes()).unwrap();
393
394 let opts = ValidateOptions {
395 check_cycles: true,
396 check_unused: true,
397 strict: false,
398 filter_types: vec![],
399 };
400
401 let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
402
403 assert_eq!(result.status, "success");
405 assert!(!result.warnings.is_empty());
406 assert!(result.warnings.iter().any(|w| w.contains("OrphanType")));
407 }
408
409 #[test]
410 fn test_validate_strict_mode() {
411 let schema = create_schema_with_unused();
412 let mut temp_file = NamedTempFile::new().unwrap();
413 temp_file.write_all(schema.as_bytes()).unwrap();
414
415 let opts = ValidateOptions {
416 check_cycles: true,
417 check_unused: true,
418 strict: true,
419 filter_types: vec![],
420 };
421
422 let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
423
424 assert_eq!(result.status, "validation-failed");
426 assert!(result.errors.iter().any(|e| e.contains("OrphanType")));
427 }
428
429 #[test]
430 fn test_validate_type_filter() {
431 let schema = create_valid_schema();
432 let mut temp_file = NamedTempFile::new().unwrap();
433 temp_file.write_all(schema.as_bytes()).unwrap();
434
435 let opts = ValidateOptions {
436 check_cycles: true,
437 check_unused: false,
438 strict: false,
439 filter_types: vec!["User".to_string()],
440 };
441
442 let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
443
444 assert_eq!(result.status, "success");
445 let data = result.data.unwrap();
446 let type_analysis = data.get("type_analysis").unwrap().as_array().unwrap();
447 assert_eq!(type_analysis.len(), 1);
448 assert_eq!(type_analysis[0]["name"], "User");
449 }
450
451 #[test]
452 fn test_validate_type_filter_not_found() {
453 let schema = create_valid_schema();
454 let mut temp_file = NamedTempFile::new().unwrap();
455 temp_file.write_all(schema.as_bytes()).unwrap();
456
457 let opts = ValidateOptions {
458 check_cycles: true,
459 check_unused: false,
460 strict: false,
461 filter_types: vec!["NonExistent".to_string()],
462 };
463
464 let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
465
466 assert_eq!(result.status, "success");
468 assert!(result.warnings.iter().any(|w| w.contains("NonExistent")));
469 }
470
471 #[test]
472 fn test_validate_result_structure() {
473 let schema = create_valid_schema();
474 let mut temp_file = NamedTempFile::new().unwrap();
475 temp_file.write_all(schema.as_bytes()).unwrap();
476
477 let opts = ValidateOptions::default();
478
479 let result = run_with_options(temp_file.path().to_str().unwrap(), opts).unwrap();
480
481 let data = result.data.unwrap();
482 assert!(data.get("schema_path").is_some());
483 assert!(data.get("valid").is_some());
484 assert!(data.get("type_count").is_some());
485 assert!(data.get("query_count").is_some());
486 assert!(data.get("mutation_count").is_some());
487 }
488}