use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use serde_json::Value;
use walkdir::WalkDir;
pub(crate) const MAX_SCHEMA_FILES: usize = 1_000;
pub struct MultiFileLoader;
pub struct LoadResult {
pub merged: Value,
}
impl MultiFileLoader {
pub fn load_from_directory(dir_path: &str) -> Result<Value> {
let result = Self::load_from_directory_with_tracking(dir_path)?;
Ok(result.merged)
}
pub fn load_from_directory_with_tracking(dir_path: &str) -> Result<LoadResult> {
let dir = Path::new(dir_path);
if !dir.is_dir() {
bail!("Schema directory not found: {dir_path}");
}
let mut merged = crate::schema::seam::empty_accumulator();
let mut name_to_file = HashMap::new();
let mut json_files = Vec::new();
for entry in WalkDir::new(dir_path)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
{
json_files.push(entry.path().to_path_buf());
if json_files.len() > MAX_SCHEMA_FILES {
bail!(
"Schema directory {dir_path:?} contains more than {MAX_SCHEMA_FILES} JSON \
files. Point --schema-dir at a directory containing only schema files."
);
}
}
json_files.sort();
for file_path in json_files {
let content = fs::read_to_string(&file_path)
.context(format!("Failed to read {}", file_path.display()))?;
let value: Value = serde_json::from_str(&content)
.context(format!("Failed to parse JSON from {}", file_path.display()))?;
let file_path_str = file_path.to_string_lossy().to_string();
Self::reject_duplicate_names(&value, &file_path_str, &mut name_to_file)?;
crate::schema::seam::absorb_sections(&mut merged, &value, &file_path_str)?;
}
Ok(LoadResult { merged })
}
pub fn load_from_paths(paths: &[PathBuf]) -> Result<Value> {
let mut merged = crate::schema::seam::empty_accumulator();
let mut name_to_file = HashMap::new();
for path in paths {
if !path.exists() {
bail!("File not found: {}", path.display());
}
let content =
fs::read_to_string(path).context(format!("Failed to read {}", path.display()))?;
let value: Value = serde_json::from_str(&content)
.context(format!("Failed to parse JSON from {}", path.display()))?;
let source = path.display().to_string();
Self::reject_duplicate_names(&value, &source, &mut name_to_file)?;
crate::schema::seam::absorb_sections(&mut merged, &value, &source)?;
}
Ok(merged)
}
fn reject_duplicate_names(
value: &Value,
source: &str,
name_to_file: &mut HashMap<String, String>,
) -> Result<()> {
for section in crate::schema::seam::AUTHORABLE_ARRAY_SECTIONS {
let Some(Value::Array(items)) = value.get(*section) else {
continue;
};
for item in items {
let Some(name) = item.get("name").and_then(|v| v.as_str()) else {
continue;
};
let key = format!("{section}:{name}");
if let Some(existing) = name_to_file.get(&key) {
let noun = crate::schema::seam::section_noun(section);
bail!("Duplicate {noun} '{name}' found in:\n - {existing}\n - {source}");
}
name_to_file.insert(key, source.to_string());
}
}
Ok(())
}
}