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;
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 merged = crate::schema::seam::empty_accumulator();
77 let mut name_to_file = HashMap::new();
78
79 // Collect all JSON files and sort for deterministic ordering
80 let mut json_files = Vec::new();
81 for entry in WalkDir::new(dir_path)
82 .into_iter()
83 .filter_map(Result::ok)
84 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
85 {
86 json_files.push(entry.path().to_path_buf());
87 if json_files.len() > MAX_SCHEMA_FILES {
88 bail!(
89 "Schema directory {dir_path:?} contains more than {MAX_SCHEMA_FILES} JSON \
90 files. Point --schema-dir at a directory containing only schema files."
91 );
92 }
93 }
94
95 json_files.sort();
96
97 // Load and merge each file
98 for file_path in json_files {
99 let content = fs::read_to_string(&file_path)
100 .context(format!("Failed to read {}", file_path.display()))?;
101 let value: Value = serde_json::from_str(&content)
102 .context(format!("Failed to parse JSON from {}", file_path.display()))?;
103
104 // Track source for each item
105 let file_path_str = file_path.to_string_lossy().to_string();
106
107 Self::reject_duplicate_names(&value, &file_path_str, &mut name_to_file)?;
108 crate::schema::seam::absorb_sections(&mut merged, &value, &file_path_str)?;
109 }
110
111 Ok(LoadResult { merged })
112 }
113
114 /// Load specific files and merge them
115 ///
116 /// # Arguments
117 /// * `paths` - Vector of file paths to load
118 ///
119 /// # Returns
120 /// Merged `Value` with "types", "queries", "mutations" as arrays.
121 ///
122 /// # Errors
123 ///
124 /// Returns an error if any path does not exist, cannot be read, or cannot
125 /// be parsed as JSON.
126 pub fn load_from_paths(paths: &[PathBuf]) -> Result<Value> {
127 let mut merged = crate::schema::seam::empty_accumulator();
128 let mut name_to_file = HashMap::new();
129
130 for path in paths {
131 if !path.exists() {
132 bail!("File not found: {}", path.display());
133 }
134
135 let content =
136 fs::read_to_string(path).context(format!("Failed to read {}", path.display()))?;
137 let value: Value = serde_json::from_str(&content)
138 .context(format!("Failed to parse JSON from {}", path.display()))?;
139
140 let source = path.display().to_string();
141 Self::reject_duplicate_names(&value, &source, &mut name_to_file)?;
142 crate::schema::seam::absorb_sections(&mut merged, &value, &source)?;
143 }
144
145 Ok(merged)
146 }
147
148 /// Refuse a name already claimed by an earlier source, in any authorable section.
149 ///
150 /// Shared by both loaders. It used to be inline in the directory loader only, so
151 /// `--schema-file a.json --schema-file b.json` (and the TOML merger, which calls
152 /// `load_from_paths`) concatenated two definitions of the same type in silence and
153 /// let argument order decide which one the compiler saw.
154 ///
155 /// Detection covers **every** named section rather than the three that used to be
156 /// merged: a duplicate enum or interface is the same authoring mistake as a duplicate
157 /// type, and keeping both produces a schema whose behaviour depends on iteration order.
158 ///
159 /// # Errors
160 ///
161 /// Returns an error naming the section, the duplicated name and both source files.
162 fn reject_duplicate_names(
163 value: &Value,
164 source: &str,
165 name_to_file: &mut HashMap<String, String>,
166 ) -> Result<()> {
167 for section in crate::schema::seam::AUTHORABLE_ARRAY_SECTIONS {
168 let Some(Value::Array(items)) = value.get(*section) else {
169 continue;
170 };
171 for item in items {
172 let Some(name) = item.get("name").and_then(|v| v.as_str()) else {
173 continue;
174 };
175 let key = format!("{section}:{name}");
176 if let Some(existing) = name_to_file.get(&key) {
177 let noun = crate::schema::seam::section_noun(section);
178 bail!("Duplicate {noun} '{name}' found in:\n - {existing}\n - {source}");
179 }
180 name_to_file.insert(key, source.to_string());
181 }
182 }
183 Ok(())
184 }
185}