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