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