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;
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}