Skip to main content

fraiseql_cli/commands/
validate.rs

1//! Schema validation command
2//!
3//! Validates schema.json with comprehensive checks including:
4//! - JSON structure validation
5//! - Type reference validation
6//! - Circular dependency detection
7//! - Unused type detection
8
9use std::fs;
10
11use anyhow::Result;
12use fraiseql_core::schema::{CompiledSchema, SchemaDependencyGraph};
13use serde::Serialize;
14
15use crate::output::CommandResult;
16
17/// Options for schema validation
18#[derive(Debug, Clone, Default)]
19pub struct ValidateOptions {
20    /// Check for circular dependencies between types
21    pub check_cycles: bool,
22
23    /// Check for unused types (types with no incoming references)
24    pub check_unused: bool,
25
26    /// Strict mode: treat warnings as errors
27    pub strict: bool,
28
29    /// Filter to specific types (empty = all types)
30    pub filter_types: Vec<String>,
31}
32
33/// Detailed validation result
34#[derive(Debug, Serialize)]
35pub struct ValidationResult {
36    /// Schema file path
37    pub schema_path: String,
38
39    /// Whether validation passed
40    pub valid: bool,
41
42    /// Number of types in schema
43    pub type_count: usize,
44
45    /// Number of queries
46    pub query_count: usize,
47
48    /// Number of mutations
49    pub mutation_count: usize,
50
51    /// Circular dependencies found (errors)
52    #[serde(skip_serializing_if = "Vec::is_empty")]
53    pub cycles: Vec<CycleError>,
54
55    /// Unused types found (warnings or errors in strict mode)
56    #[serde(skip_serializing_if = "Vec::is_empty")]
57    pub unused_types: Vec<String>,
58
59    /// Type-specific analysis (when --types filter is used)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub type_analysis: Option<Vec<TypeAnalysis>>,
62}
63
64/// Information about a circular dependency
65#[derive(Debug, Serialize)]
66pub struct CycleError {
67    /// Types involved in the cycle
68    pub types: Vec<String>,
69    /// Human-readable path
70    pub path:  String,
71}
72
73/// Analysis of a specific type
74#[derive(Debug, Serialize)]
75pub struct TypeAnalysis {
76    /// Type name
77    pub name:                    String,
78    /// Types this type depends on
79    pub dependencies:            Vec<String>,
80    /// Types that depend on this type
81    pub dependents:              Vec<String>,
82    /// Transitive dependencies (all types reachable)
83    pub transitive_dependencies: Vec<String>,
84}
85
86/// Run validation with options and return structured result
87///
88/// # Errors
89///
90/// Returns an error if the schema file cannot be read, cannot be deserialized as
91/// a `CompiledSchema`, or if JSON serialization of the result fails.
92pub fn run_with_options(input: &str, opts: ValidateOptions) -> Result<CommandResult> {
93    // Load and parse schema
94    let schema_content = fs::read_to_string(input)?;
95    let schema: CompiledSchema = serde_json::from_str(&schema_content)?;
96
97    // Build dependency graph
98    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    // Check for circular dependencies
106    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    // Check for unused types
119    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    // Type-specific analysis
132    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    // Build result
156    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}