1use 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
19pub struct MultiFileLoader;
21
22pub struct LoadResult {
24 pub merged: Value,
26}
27
28impl MultiFileLoader {
29 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 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 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 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 let file_path_str = file_path.to_string_lossy().to_string();
84
85 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 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 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 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 if let Some(Value::Array(type_items)) = value.get("types") {
167 types.extend(type_items.clone());
168 }
169
170 if let Some(Value::Array(query_items)) = value.get("queries") {
172 queries.extend(query_items.clone());
173 }
174
175 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 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 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}