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/// Loads and merges JSON schema files from directories
20pub struct MultiFileLoader;
21
22/// Result of loading files
23pub struct LoadResult {
24    /// Merged JSON value with types, queries, mutations arrays
25    pub merged: Value,
26}
27
28impl MultiFileLoader {
29    /// Load and merge all JSON files from a directory recursively
30    ///
31    /// # Arguments
32    /// * `dir_path` - Path to directory containing *.json files
33    ///
34    /// # Returns
35    /// Merged Value with "types", "queries", "mutations" as arrays
36    ///
37    /// # Errors
38    /// - If directory doesn't exist
39    /// - If JSON parsing fails
40    /// - If duplicate names are found (with file paths)
41    ///
42    /// # Example
43    /// ```ignore
44    /// let merged = MultiFileLoader::load_from_directory("schema/")?;
45    /// ```
46    pub fn load_from_directory(dir_path: &str) -> Result<Value> {
47        let result = Self::load_from_directory_with_tracking(dir_path)?;
48        Ok(result.merged)
49    }
50
51    /// Load from directory with file path tracking for conflict detection
52    pub fn load_from_directory_with_tracking(dir_path: &str) -> Result<LoadResult> {
53        let dir = Path::new(dir_path);
54        if !dir.is_dir() {
55            bail!("Schema directory not found: {dir_path}");
56        }
57
58        let mut types = Vec::new();
59        let mut queries = Vec::new();
60        let mut mutations = Vec::new();
61        let mut name_to_file = HashMap::new();
62
63        // Collect all JSON files and sort for deterministic ordering
64        let mut json_files = Vec::new();
65        for entry in WalkDir::new(dir_path)
66            .into_iter()
67            .filter_map(Result::ok)
68            .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
69        {
70            json_files.push(entry.path().to_path_buf());
71        }
72
73        json_files.sort();
74
75        // Load and merge each file
76        for file_path in json_files {
77            let content = fs::read_to_string(&file_path)
78                .context(format!("Failed to read {}", file_path.display()))?;
79            let value: Value = serde_json::from_str(&content)
80                .context(format!("Failed to parse JSON from {}", file_path.display()))?;
81
82            // Track source for each item
83            let file_path_str = file_path.to_string_lossy().to_string();
84
85            // Merge types
86            if let Some(Value::Array(type_items)) = value.get("types") {
87                for item in type_items {
88                    if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
89                        let type_key = format!("type:{name}");
90                        if let Some(existing) = name_to_file.get(&type_key) {
91                            bail!(
92                                "Duplicate type '{name}' found in:\n  - {existing}\n  - {file_path_str}"
93                            );
94                        }
95                        name_to_file.insert(type_key, file_path_str.clone());
96                    }
97                    types.push(item.clone());
98                }
99            }
100
101            // Merge queries
102            if let Some(Value::Array(query_items)) = value.get("queries") {
103                for item in query_items {
104                    if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
105                        let query_key = format!("query:{name}");
106                        if let Some(existing) = name_to_file.get(&query_key) {
107                            bail!(
108                                "Duplicate query '{name}' found in:\n  - {existing}\n  - {file_path_str}"
109                            );
110                        }
111                        name_to_file.insert(query_key, file_path_str.clone());
112                    }
113                    queries.push(item.clone());
114                }
115            }
116
117            // Merge mutations
118            if let Some(Value::Array(mutation_items)) = value.get("mutations") {
119                for item in mutation_items {
120                    if let Some(name) = item.get("name").and_then(|v| v.as_str()) {
121                        let mutation_key = format!("mutation:{name}");
122                        if let Some(existing) = name_to_file.get(&mutation_key) {
123                            bail!(
124                                "Duplicate mutation '{name}' found in:\n  - {existing}\n  - {file_path_str}"
125                            );
126                        }
127                        name_to_file.insert(mutation_key, file_path_str.clone());
128                    }
129                    mutations.push(item.clone());
130                }
131            }
132        }
133
134        let merged = json!({
135            "types": types,
136            "queries": queries,
137            "mutations": mutations,
138        });
139
140        Ok(LoadResult { merged })
141    }
142
143    /// Load specific files and merge them
144    ///
145    /// # Arguments
146    /// * `paths` - Vector of file paths to load
147    ///
148    /// # Returns
149    /// Merged Value with "types", "queries", "mutations" as arrays
150    pub fn load_from_paths(paths: &[PathBuf]) -> Result<Value> {
151        let mut types = Vec::new();
152        let mut queries = Vec::new();
153        let mut mutations = Vec::new();
154
155        for path in paths {
156            if !path.exists() {
157                bail!("File not found: {}", path.display());
158            }
159
160            let content =
161                fs::read_to_string(path).context(format!("Failed to read {}", path.display()))?;
162            let value: Value = serde_json::from_str(&content)
163                .context(format!("Failed to parse JSON from {}", path.display()))?;
164
165            // Merge types
166            if let Some(Value::Array(type_items)) = value.get("types") {
167                types.extend(type_items.clone());
168            }
169
170            // Merge queries
171            if let Some(Value::Array(query_items)) = value.get("queries") {
172                queries.extend(query_items.clone());
173            }
174
175            // Merge mutations
176            if let Some(Value::Array(mutation_items)) = value.get("mutations") {
177                mutations.extend(mutation_items.clone());
178            }
179        }
180
181        Ok(json!({
182            "types": types,
183            "queries": queries,
184            "mutations": mutations,
185        }))
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use std::fs;
192
193    use tempfile::TempDir;
194
195    use super::*;
196
197    fn create_test_file(dir: &Path, name: &str, content: &str) -> Result<()> {
198        let path = dir.join(name);
199        fs::write(path, content)?;
200        Ok(())
201    }
202
203    #[test]
204    fn test_load_single_type_file() -> Result<()> {
205        let temp_dir = TempDir::new()?;
206        let schema = json!({
207            "types": [
208                {"name": "User", "fields": []}
209            ],
210            "queries": [],
211            "mutations": []
212        });
213        create_test_file(temp_dir.path(), "types.json", &schema.to_string())?;
214
215        let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
216
217        assert_eq!(result["types"].as_array().unwrap().len(), 1);
218        assert_eq!(result["types"][0]["name"], "User");
219        assert_eq!(result["queries"].as_array().unwrap().len(), 0);
220        assert_eq!(result["mutations"].as_array().unwrap().len(), 0);
221
222        Ok(())
223    }
224
225    #[test]
226    fn test_merge_multiple_type_files() -> Result<()> {
227        let temp_dir = TempDir::new()?;
228
229        let user_schema = json!({
230            "types": [
231                {"name": "User", "fields": []}
232            ],
233            "queries": [],
234            "mutations": []
235        });
236        create_test_file(temp_dir.path(), "user.json", &user_schema.to_string())?;
237
238        let post_schema = json!({
239            "types": [
240                {"name": "Post", "fields": []}
241            ],
242            "queries": [],
243            "mutations": []
244        });
245        create_test_file(temp_dir.path(), "post.json", &post_schema.to_string())?;
246
247        let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
248
249        assert_eq!(result["types"].as_array().unwrap().len(), 2);
250        let type_names: Vec<&str> = result["types"]
251            .as_array()
252            .unwrap()
253            .iter()
254            .filter_map(|t| t["name"].as_str())
255            .collect();
256        assert!(type_names.contains(&"User"));
257        assert!(type_names.contains(&"Post"));
258
259        Ok(())
260    }
261
262    #[test]
263    fn test_merge_respects_alphabetical_order() -> Result<()> {
264        let temp_dir = TempDir::new()?;
265
266        let c_schema = json!({
267            "types": [{"name": "C", "fields": []}],
268            "queries": [],
269            "mutations": []
270        });
271        create_test_file(temp_dir.path(), "c.json", &c_schema.to_string())?;
272
273        let a_schema = json!({
274            "types": [{"name": "A", "fields": []}],
275            "queries": [],
276            "mutations": []
277        });
278        create_test_file(temp_dir.path(), "a.json", &a_schema.to_string())?;
279
280        let b_schema = json!({
281            "types": [{"name": "B", "fields": []}],
282            "queries": [],
283            "mutations": []
284        });
285        create_test_file(temp_dir.path(), "b.json", &b_schema.to_string())?;
286
287        let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
288
289        let type_names: Vec<&str> = result["types"]
290            .as_array()
291            .unwrap()
292            .iter()
293            .filter_map(|t| t["name"].as_str())
294            .collect();
295
296        // Should be ordered by file load order (a.json, b.json, c.json alphabetically)
297        assert_eq!(type_names[0], "A");
298        assert_eq!(type_names[1], "B");
299        assert_eq!(type_names[2], "C");
300
301        Ok(())
302    }
303
304    #[test]
305    fn test_merge_queries_and_mutations() -> Result<()> {
306        let temp_dir = TempDir::new()?;
307
308        let schema = json!({
309            "types": [
310                {"name": "User", "fields": []}
311            ],
312            "queries": [
313                {"name": "getUser", "return_type": "User"}
314            ],
315            "mutations": [
316                {"name": "createUser", "return_type": "User"}
317            ]
318        });
319        create_test_file(temp_dir.path(), "schema.json", &schema.to_string())?;
320
321        let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
322
323        assert_eq!(result["types"].as_array().unwrap().len(), 1);
324        assert_eq!(result["queries"].as_array().unwrap().len(), 1);
325        assert_eq!(result["queries"][0]["name"], "getUser");
326        assert_eq!(result["mutations"].as_array().unwrap().len(), 1);
327        assert_eq!(result["mutations"][0]["name"], "createUser");
328
329        Ok(())
330    }
331
332    #[test]
333    fn test_nested_directory_structure() -> Result<()> {
334        let temp_dir = TempDir::new()?;
335
336        // Create nested structure
337        fs::create_dir_all(temp_dir.path().join("types"))?;
338        fs::create_dir_all(temp_dir.path().join("queries"))?;
339
340        let user_type = json!({
341            "types": [{"name": "User", "fields": []}],
342            "queries": [],
343            "mutations": []
344        });
345        create_test_file(
346            temp_dir.path().join("types").as_path(),
347            "user.json",
348            &user_type.to_string(),
349        )?;
350
351        let post_type = json!({
352            "types": [{"name": "Post", "fields": []}],
353            "queries": [],
354            "mutations": []
355        });
356        create_test_file(
357            temp_dir.path().join("types").as_path(),
358            "post.json",
359            &post_type.to_string(),
360        )?;
361
362        let user_queries = json!({
363            "types": [],
364            "queries": [{"name": "getUser", "return_type": "User"}],
365            "mutations": []
366        });
367        create_test_file(
368            temp_dir.path().join("queries").as_path(),
369            "user_queries.json",
370            &user_queries.to_string(),
371        )?;
372
373        let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
374
375        assert_eq!(result["types"].as_array().unwrap().len(), 2);
376        assert_eq!(result["queries"].as_array().unwrap().len(), 1);
377
378        Ok(())
379    }
380
381    #[test]
382    fn test_duplicate_type_names_error() -> Result<()> {
383        let temp_dir = TempDir::new()?;
384
385        let file1 = json!({
386            "types": [{"name": "User", "fields": []}],
387            "queries": [],
388            "mutations": []
389        });
390        create_test_file(temp_dir.path(), "file1.json", &file1.to_string())?;
391
392        let file2 = json!({
393            "types": [{"name": "User", "fields": []}],
394            "queries": [],
395            "mutations": []
396        });
397        create_test_file(temp_dir.path(), "file2.json", &file2.to_string())?;
398
399        let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap());
400
401        assert!(result.is_err());
402        let err_msg = result.unwrap_err().to_string();
403        assert!(err_msg.contains("Duplicate type 'User'"));
404        assert!(err_msg.contains("file1.json"));
405        assert!(err_msg.contains("file2.json"));
406
407        Ok(())
408    }
409
410    #[test]
411    fn test_duplicate_query_names_error() -> Result<()> {
412        let temp_dir = TempDir::new()?;
413
414        let file1 = json!({
415            "types": [],
416            "queries": [{"name": "getUser", "return_type": "User"}],
417            "mutations": []
418        });
419        create_test_file(temp_dir.path(), "file1.json", &file1.to_string())?;
420
421        let file2 = json!({
422            "types": [],
423            "queries": [{"name": "getUser", "return_type": "User"}],
424            "mutations": []
425        });
426        create_test_file(temp_dir.path(), "file2.json", &file2.to_string())?;
427
428        let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap());
429
430        assert!(result.is_err());
431        let err_msg = result.unwrap_err().to_string();
432        assert!(err_msg.contains("Duplicate query 'getUser'"));
433
434        Ok(())
435    }
436
437    #[test]
438    fn test_empty_directory() -> Result<()> {
439        let temp_dir = TempDir::new()?;
440
441        let result = MultiFileLoader::load_from_directory(temp_dir.path().to_str().unwrap())?;
442
443        assert_eq!(result["types"].as_array().unwrap().len(), 0);
444        assert_eq!(result["queries"].as_array().unwrap().len(), 0);
445        assert_eq!(result["mutations"].as_array().unwrap().len(), 0);
446
447        Ok(())
448    }
449
450    #[test]
451    fn test_nonexistent_directory() {
452        let result = MultiFileLoader::load_from_directory("/nonexistent/path/to/schema");
453        assert!(result.is_err());
454    }
455
456    #[test]
457    fn test_load_from_paths() -> Result<()> {
458        let temp_dir = TempDir::new()?;
459
460        let schema1 = json!({
461            "types": [{"name": "User", "fields": []}],
462            "queries": [],
463            "mutations": []
464        });
465        create_test_file(temp_dir.path(), "schema1.json", &schema1.to_string())?;
466
467        let schema2 = json!({
468            "types": [{"name": "Post", "fields": []}],
469            "queries": [],
470            "mutations": []
471        });
472        create_test_file(temp_dir.path(), "schema2.json", &schema2.to_string())?;
473
474        let paths = vec![
475            temp_dir.path().join("schema1.json"),
476            temp_dir.path().join("schema2.json"),
477        ];
478
479        let result = MultiFileLoader::load_from_paths(&paths)?;
480
481        assert_eq!(result["types"].as_array().unwrap().len(), 2);
482
483        Ok(())
484    }
485}