use std::collections::HashMap;
use std::fmt;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum LoadError {
NotFound { namespace: String },
Ambiguous {
namespace: String,
paths: Vec<PathBuf>,
},
Io { namespace: String, message: String },
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LoadError::NotFound { namespace } => {
write!(f, "schema not found: `{namespace}`")
}
LoadError::Ambiguous { namespace, paths } => {
let list: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
write!(
f,
"schema `{namespace}` is ambiguous — found in: {}",
list.join(", ")
)
}
LoadError::Io { namespace, message } => {
write!(f, "I/O error loading `{namespace}`: {message}")
}
}
}
}
pub trait SchemaLoader {
fn load(&self, namespace: &[&str]) -> Result<(String, PathBuf), LoadError>;
}
#[derive(Debug, Default, Clone)]
pub struct InMemoryLoader {
pub schemas: HashMap<String, String>,
}
impl InMemoryLoader {
pub fn new() -> Self {
Self::default()
}
}
impl SchemaLoader for InMemoryLoader {
fn load(&self, namespace: &[&str]) -> Result<(String, PathBuf), LoadError> {
let key = namespace.join(".");
match self.schemas.get(&key) {
Some(src) => {
let path = PathBuf::from(format!("<memory>/{key}"));
Ok((src.clone(), path))
}
None => Err(LoadError::NotFound { namespace: key }),
}
}
}
#[derive(Debug, Clone)]
pub struct FilesystemLoader {
roots: Vec<PathBuf>,
}
impl FilesystemLoader {
pub fn new(roots: impl IntoIterator<Item = impl Into<PathBuf>>) -> Self {
Self {
roots: roots.into_iter().map(Into::into).collect(),
}
}
}
impl SchemaLoader for FilesystemLoader {
fn load(&self, namespace: &[&str]) -> Result<(String, PathBuf), LoadError> {
let dotted = namespace.join(".");
let mut found: Vec<PathBuf> = Vec::new();
for start in 0..namespace.len() {
let mut rel = PathBuf::new();
for seg in &namespace[start..] {
rel.push(seg);
}
rel.set_extension("vexil");
for root in &self.roots {
let candidate = root.join(&rel);
if candidate.is_file() && !found.contains(&candidate) {
found.push(candidate);
}
}
if !found.is_empty() {
break;
}
}
match found.len() {
0 => Err(LoadError::NotFound { namespace: dotted }),
1 => {
let path = found.remove(0);
let src = std::fs::read_to_string(&path).map_err(|e| LoadError::Io {
namespace: dotted,
message: e.to_string(),
})?;
Ok((src, path))
}
_ => Err(LoadError::Ambiguous {
namespace: dotted,
paths: found,
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn in_memory_loader_found() {
let mut loader = InMemoryLoader::new();
loader
.schemas
.insert("foo.bar.types".into(), "schema foo.bar.types {}".into());
let (src, path) = loader.load(&["foo", "bar", "types"]).unwrap();
assert!(
src.contains("foo.bar.types"),
"source should contain namespace"
);
assert_eq!(path, PathBuf::from("<memory>/foo.bar.types"));
}
#[test]
fn in_memory_loader_not_found() {
let loader = InMemoryLoader::new();
let err = loader.load(&["does", "not", "exist"]).unwrap_err();
assert!(
matches!(err, LoadError::NotFound { ref namespace } if namespace == "does.not.exist"),
"expected NotFound, got {err:?}"
);
}
#[test]
fn filesystem_loader_finds_file() {
let dir = tempdir();
let nested = dir.path().join("foo").join("bar");
fs::create_dir_all(&nested).unwrap();
let file = nested.join("types.vexil");
fs::write(&file, "schema foo.bar.types {}").unwrap();
let loader = FilesystemLoader::new([dir.path()]);
let (src, path) = loader.load(&["foo", "bar", "types"]).unwrap();
assert!(src.contains("foo.bar.types"));
assert_eq!(path, file);
}
#[test]
fn filesystem_loader_not_found() {
let dir = tempdir();
let loader = FilesystemLoader::new([dir.path()]);
let err = loader.load(&["missing", "schema"]).unwrap_err();
assert!(
matches!(err, LoadError::NotFound { .. }),
"expected NotFound, got {err:?}"
);
}
#[test]
fn filesystem_loader_ambiguous() {
let dir1 = tempdir();
let dir2 = tempdir();
for dir in [dir1.path(), dir2.path()] {
let nested = dir.join("net").join("types");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("core.vexil"), "schema net.types.core {}").unwrap();
}
let loader = FilesystemLoader::new([dir1.path(), dir2.path()]);
let err = loader.load(&["net", "types", "core"]).unwrap_err();
assert!(
matches!(&err, LoadError::Ambiguous { namespace, paths }
if namespace == "net.types.core" && paths.len() == 2),
"expected Ambiguous with 2 paths, got {err:?}"
);
}
#[test]
fn filesystem_loader_prefix_stripped() {
let dir = tempdir();
let file = dir.path().join("common.vexil");
fs::write(&file, "namespace malt.common").unwrap();
let loader = FilesystemLoader::new([dir.path()]);
let (src, path) = loader.load(&["malt", "common"]).unwrap();
assert!(src.contains("malt.common"));
assert_eq!(path, file);
}
#[test]
fn filesystem_loader_prefers_full_path_over_stripped() {
let dir = tempdir();
let nested = dir.path().join("malt");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("common.vexil"), "namespace malt.common (full)").unwrap();
fs::write(
dir.path().join("common.vexil"),
"namespace malt.common (stripped)",
)
.unwrap();
let loader = FilesystemLoader::new([dir.path()]);
let (src, _) = loader.load(&["malt", "common"]).unwrap();
assert!(src.contains("(full)"), "should prefer full path match");
}
struct TempDir(PathBuf);
impl TempDir {
fn path(&self) -> &PathBuf {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn tempdir() -> TempDir {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("vexil_resolve_test_{n}_{}", std::process::id()));
fs::create_dir_all(&path).unwrap();
TempDir(path)
}
}