fraiseql_cli/commands/
validate.rs1use std::fs;
10
11use anyhow::{Context, Result};
12use fraiseql_core::schema::{CompiledSchema, SchemaDependencyGraph};
13use serde::Serialize;
14
15use crate::{
16 output::CommandResult,
17 schema::{mutation_contract::validate_mutation_contract, pg_catalog::PgCatalog},
18};
19
20#[derive(Debug, Clone, Default)]
22pub struct ValidateOptions {
23 pub check_cycles: bool,
25
26 pub check_unused: bool,
28
29 pub strict: bool,
31
32 pub filter_types: Vec<String>,
34}
35
36#[derive(Debug, Serialize)]
38pub struct ValidationResult {
39 pub schema_path: String,
41
42 pub valid: bool,
44
45 pub type_count: usize,
47
48 pub query_count: usize,
50
51 pub mutation_count: usize,
53
54 #[serde(skip_serializing_if = "Vec::is_empty")]
56 pub cycles: Vec<CycleError>,
57
58 #[serde(skip_serializing_if = "Vec::is_empty")]
60 pub unused_types: Vec<String>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub type_analysis: Option<Vec<TypeAnalysis>>,
65}
66
67#[derive(Debug, Serialize)]
69pub struct CycleError {
70 pub types: Vec<String>,
72 pub path: String,
74}
75
76#[derive(Debug, Serialize)]
78pub struct TypeAnalysis {
79 pub name: String,
81 pub dependencies: Vec<String>,
83 pub dependents: Vec<String>,
85 pub transitive_dependencies: Vec<String>,
87}
88
89pub fn run_with_options(input: &str, opts: ValidateOptions) -> Result<CommandResult> {
96 let schema_content = fs::read_to_string(input)?;
98 let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
99
100 let graph = SchemaDependencyGraph::build(&schema);
102
103 let mut errors: Vec<String> = Vec::new();
104 let mut warnings: Vec<String> = Vec::new();
105 let mut cycles: Vec<CycleError> = Vec::new();
106 let mut unused_types: Vec<String> = Vec::new();
107
108 if opts.check_cycles {
110 let detected_cycles = graph.find_cycles();
111 for cycle in detected_cycles {
112 let cycle_error = CycleError {
113 types: cycle.nodes.clone(),
114 path: cycle.path_string(),
115 };
116 errors.push(format!("Circular dependency: {}", cycle.path_string()));
117 cycles.push(cycle_error);
118 }
119 }
120
121 if opts.check_unused {
123 let detected_unused = graph.find_unused();
124 for type_name in detected_unused {
125 if opts.strict {
126 errors.push(format!("Unused type: '{type_name}' has no incoming references"));
127 } else {
128 warnings.push(format!("Unused type: '{type_name}' has no incoming references"));
129 }
130 unused_types.push(type_name);
131 }
132 }
133
134 let type_analysis = if opts.filter_types.is_empty() {
136 None
137 } else {
138 let mut analyses = Vec::new();
139 for type_name in &opts.filter_types {
140 if graph.has_type(type_name) {
141 let deps = graph.dependencies_of(type_name);
142 let refs = graph.dependents_of(type_name);
143 let transitive = graph.transitive_dependencies(type_name);
144
145 analyses.push(TypeAnalysis {
146 name: type_name.clone(),
147 dependencies: deps,
148 dependents: refs,
149 transitive_dependencies: transitive.into_iter().collect(),
150 });
151 } else {
152 warnings.push(format!("Type '{type_name}' not found in schema"));
153 }
154 }
155 Some(analyses)
156 };
157
158 let result = ValidationResult {
160 schema_path: input.to_string(),
161 valid: errors.is_empty(),
162 type_count: schema.types.len(),
163 query_count: schema.queries.len(),
164 mutation_count: schema.mutations.len(),
165 cycles,
166 unused_types,
167 type_analysis,
168 };
169
170 let data = serde_json::to_value(&result)?;
171
172 if !errors.is_empty() {
173 Ok(CommandResult {
174 status: "validation-failed".to_string(),
175 command: "validate".to_string(),
176 data: Some(data),
177 message: Some(format!("{} validation error(s) found", errors.len())),
178 code: Some("VALIDATION_FAILED".to_string()),
179 errors,
180 warnings,
181 })
182 } else if !warnings.is_empty() {
183 Ok(CommandResult::success_with_warnings("validate", data, warnings))
184 } else {
185 Ok(CommandResult::success("validate", data))
186 }
187}
188
189pub async fn run_against_db(input: &str, db_url: &str, json: bool) -> Result<()> {
200 let schema_content = fs::read_to_string(input)
201 .with_context(|| format!("failed to read compiled schema `{input}`"))?;
202 let schema: CompiledSchema = serde_json::from_str(&schema_content).with_context(|| {
203 format!("`{input}` is not a valid compiled schema (run `fraiseql compile` first)")
204 })?;
205
206 let catalog = PgCatalog::connect(db_url)?;
207 let report = validate_mutation_contract(&schema, &catalog).await?;
208
209 if json {
210 println!("{}", serde_json::to_string_pretty(&report.to_json())?);
211 } else {
212 report.print_text();
213 }
214
215 if report.error_count() > 0 {
216 anyhow::bail!("mutation contract validation failed: {} error(s)", report.error_count());
217 }
218 Ok(())
219}