fraiseql_cli/commands/
validate.rs1use 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}