Skip to main content

fraiseql_cli/schema/
multi_file_loader.rs

1//! Multi-file schema loader - loads and merges JSON schema files from directories
2//!
3//! Supports flexible schema composition from single files to deeply nested directory structures:
4//! - Load all *.json files from a directory recursively
5//! - Merge types, queries, mutations arrays
6//! - Deduplicate by name with error reporting
7//! - Preserve file path information for error messages
8
9use std::{
10    collections::HashMap,
11    fs,
12    path::{Path, PathBuf},
13};
14
15use anyhow::{Context, Result, bail};
16use serde_json::{Value, json};
17use walkdir::WalkDir;
18
19/// Maximum number of JSON schema files accepted from a single directory tree.
20///
21/// Prevents runaway resource use when pointed at an unexpectedly large directory
22/// (e.g. a mounted filesystem root or a node_modules tree).
23pub(crate) const MAX_SCHEMA_FILES: usize = 1_000;
24
25/// Loads and merges JSON schema files from directories
26pub struct MultiFileLoader;
27
28/// Result of loading files
29pub struct LoadResult {
30    /// Merged JSON value with types, queries, mutations arrays
31    pub merged: Value,
32}
33
34impl MultiFileLoader {
35    /// Load and merge all JSON files from a directory recursively
36    ///
37    /// # Arguments
38    /// * `dir_path` - Path to directory containing *.json files
39    ///
40    /// # Returns
41    /// Merged Value with "types", "queries", "mutations" as arrays
42    ///
43    /// # Errors
44    /// - If directory doesn't exist
45    /// - If JSON parsing fails
46    /// - If duplicate names are found (with file paths)
47    ///
48    /// # Example
49    /// ```no_run
50    /// // Requires: a "schema/" directory containing JSON schema files on disk.
51    /// use fraiseql_cli::schema::multi_file_loader::MultiFileLoader;
52    ///
53    /// # fn example() -> anyhow::Result<()> {
54    /// let merged = MultiFileLoader::load_from_directory("schema/")?;
55    /// # Ok(())
56    /// # }
57    /// ```
58    pub fn load_from_directory(dir_path: &str) -> Result<Value> {
59        let result = Self::load_from_directory_with_tracking(dir_path)?;
60        Ok(result.merged)
61    }
62
63    /// Load from directory with file path tracking for conflict detection
64    ///
65    /// # Errors
66    ///
67    /// Returns an error if `dir_path` is not a directory, if more than
68    /// `MAX_SCHEMA_FILES` JSON files are found, if any file cannot be read or
69    /// parsed as JSON, or if duplicate type/query/mutation names are detected.
70    pub fn load_from_directory_with_tracking(dir_path: &str) -> Result<LoadResult> {
71        let dir = Path::new(dir_path);
72        if !dir.is_dir() {
73            bail!("Schema directory not found: {dir_path}");
74        }
75
76        let mut types = Vec::new();
77        let mut queries = Vec::new();
78        let mut mutations = Vec::new();
79        let mut name_to_file = HashMap::new();
80
81        // Collect all JSON files and sort for deterministic ordering
82        let mut json_files = Vec::new();
83        for entry in WalkDir::new(dir_path)
84            .into_iter()
85            .filter_map(Result::ok)
86            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
87        {
88            json_files.push(entry.path().to_path_buf());
89            if json_files.len() > MAX_SCHEMA_FILES {
90                bail!(
91                    "Schema directory {dir_path:?} contains more than {MAX_SCHEMA_FILES} JSON \
92                     files. Point --schema-dir at a directory containing only schema files."
93                );
94            }
95        }
96
97        json_files.sort();
98
99        // Load and merge each file
100        for file_path in json_files {
101            let content = fs::read_to_string(&file_path)
102                .context(format!("Failed to read {}", file_path.display()))?;
103            let value: Value = serde_json::from_str(&content)
104                .context(format!("Failed to parse JSON from {}", file_path.display()))?;
105
106            // Track source for each item
107            let file_path_str = file_path.to_string_lossy().to_string();
108
109            // Merge types
110            if let Some(Value::Array(type_items)) = value.get("types") {
111                for item in type_items {
112                    if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
113                        let type_key = format!("type:{name}");
114                        if let Some(existing) = name_to_file.get(&type_key) {
115                            bail!(
116                                "Duplicate type '{name}' found in:\n  - {existing}\n  - {file_path_str}"
117                            );
118                        }
119                        name_to_file.insert(type_key, file_path_str.clone());
120                    }
121                    types.push(item.clone());
122                }
123            }
124
125            // Merge queries
126            if let Some(Value::Array(query_items)) = value.get("queries") {
127                for item in query_items {
128                    if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
129                        let query_key = format!("query:{name}");
130                        if let Some(existing) = name_to_file.get(&query_key) {
131                            bail!(
132                                "Duplicate query '{name}' found in:\n  - {existing}\n  - {file_path_str}"
133                            );
134                        }
135                        name_to_file.insert(query_key, file_path_str.clone());
136                    }
137                    queries.push(item.clone());
138                }
139            }
140
141            // Merge mutations
142            if let Some(Value::Array(mutation_items)) = value.get("mutations") {
143                for item in mutation_items {
144                    if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
145                        let mutation_key = format!("mutation:{name}");
146                        if let Some(existing) = name_to_file.get(&mutation_key) {
147                            bail!(
148                                "Duplicate mutation '{name}' found in:\n  - {existing}\n  - {file_path_str}"
149                            );
150                        }
151                        name_to_file.insert(mutation_key, file_path_str.clone());
152                    }
153                    mutations.push(item.clone());
154                }
155            }
156        }
157
158        let merged = json!({
159            "types": types,
160            "queries": queries,
161            "mutations": mutations,
162        });
163
164        Ok(LoadResult { merged })
165    }
166
167    /// Load specific files and merge them
168    ///
169    /// # Arguments
170    /// * `paths` - Vector of file paths to load
171    ///
172    /// # Returns
173    /// Merged `Value` with "types", "queries", "mutations" as arrays.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if any path does not exist, cannot be read, or cannot
178    /// be parsed as JSON.
179    pub fn load_from_paths(paths: &[PathBuf]) -> Result<Value> {
180        let mut types = Vec::new();
181        let mut queries = Vec::new();
182        let mut mutations = Vec::new();
183
184        for path in paths {
185            if !path.exists() {
186                bail!("File not found: {}", path.display());
187            }
188
189            let content =
190                fs::read_to_string(path).context(format!("Failed to read {}", path.display()))?;
191            let value: Value = serde_json::from_str(&content)
192                .context(format!("Failed to parse JSON from {}", path.display()))?;
193
194            // Merge types
195            if let Some(Value::Array(type_items)) = value.get("types") {
196                types.extend(type_items.clone());
197            }
198
199            // Merge queries
200            if let Some(Value::Array(query_items)) = value.get("queries") {
201                queries.extend(query_items.clone());
202            }
203
204            // Merge mutations
205            if let Some(Value::Array(mutation_items)) = value.get("mutations") {
206                mutations.extend(mutation_items.clone());
207            }
208        }
209
210        Ok(json!({
211            "types": types,
212            "queries": queries,
213            "mutations": mutations,
214        }))
215    }
216}