use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ModuleSource {
pub content: String,
pub path: PathBuf,
}
pub struct Loader {
cache: HashMap<String, ModuleSource>,
root_path: PathBuf,
}
impl Loader {
pub fn new(root_path: PathBuf) -> Self {
Loader {
cache: HashMap::new(),
root_path,
}
}
pub fn resolve(&mut self, base_path: &Path, uri: &str) -> Result<&ModuleSource, String> {
let cache_key = self.normalize_uri(base_path, uri)?;
if self.cache.contains_key(&cache_key) {
return Ok(&self.cache[&cache_key]);
}
let source = if uri.starts_with("file:") {
self.load_file(base_path, uri)?
} else if uri.starts_with("logos:") {
self.load_intrinsic(uri)?
} else if uri.starts_with("https://") || uri.starts_with("http://") {
return Err(format!(
"Remote module loading not supported for '{}'. \
Use the CLI's 'logos fetch' command to download dependencies locally.",
uri
));
} else {
self.load_file(base_path, &format!("file:{}", uri))?
};
self.cache.insert(cache_key.clone(), source);
Ok(&self.cache[&cache_key])
}
fn normalize_uri(&self, base_path: &Path, uri: &str) -> Result<String, String> {
if uri.starts_with("file:") {
let path_str = uri.trim_start_matches("file:");
let base_dir = base_path.parent().unwrap_or(&self.root_path);
let resolved = base_dir.join(path_str);
Ok(format!("file:{}", resolved.display()))
} else {
Ok(uri.to_string())
}
}
fn load_file(&self, base_path: &Path, uri: &str) -> Result<ModuleSource, String> {
let path_str = uri.trim_start_matches("file:");
let base_dir = base_path.parent().unwrap_or(&self.root_path);
let resolved_path = base_dir.join(path_str);
let canonical_root = self.root_path.canonicalize()
.unwrap_or_else(|_| self.root_path.clone());
let content = fs::read_to_string(&resolved_path)
.map_err(|e| format!("Failed to read '{}': {}", resolved_path.display(), e))?;
if let Ok(canonical_path) = resolved_path.canonicalize() {
if !canonical_path.starts_with(&canonical_root) {
return Err(format!(
"Security: Cannot load '{}' - path escapes project root",
uri
));
}
}
Ok(ModuleSource {
content,
path: resolved_path,
})
}
fn load_intrinsic(&self, uri: &str) -> Result<ModuleSource, String> {
let name = uri.trim_start_matches("logos:");
match name {
"std" => Ok(ModuleSource {
content: include_str!("../assets/std/std.md").to_string(),
path: PathBuf::from("logos:std"),
}),
"core" => Ok(ModuleSource {
content: include_str!("../assets/std/core.md").to_string(),
path: PathBuf::from("logos:core"),
}),
_ => Err(format!("Unknown intrinsic module: '{}'", uri)),
}
}
pub fn is_loaded(&self, uri: &str) -> bool {
self.cache.contains_key(uri)
}
pub fn loaded_modules(&self) -> Vec<&str> {
self.cache.keys().map(|s| s.as_str()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_file_scheme_resolution() {
let temp_dir = tempdir().unwrap();
let geo_path = temp_dir.path().join("geo.md");
fs::write(&geo_path, "## Definition\nA Point has:\n an x, which is Int.\n").unwrap();
let mut loader = Loader::new(temp_dir.path().to_path_buf());
let result = loader.resolve(&temp_dir.path().join("main.md"), "file:./geo.md");
assert!(result.is_ok(), "Should resolve file: scheme: {:?}", result);
assert!(result.unwrap().content.contains("Point"));
}
#[test]
fn test_logos_std_scheme() {
let mut loader = Loader::new(PathBuf::from("."));
let result = loader.resolve(&PathBuf::from("main.md"), "logos:std");
assert!(result.is_ok(), "Should resolve logos:std: {:?}", result);
}
#[test]
fn test_logos_core_scheme() {
let mut loader = Loader::new(PathBuf::from("."));
let result = loader.resolve(&PathBuf::from("main.md"), "logos:core");
assert!(result.is_ok(), "Should resolve logos:core: {:?}", result);
}
#[test]
fn test_unknown_intrinsic() {
let mut loader = Loader::new(PathBuf::from("."));
let result = loader.resolve(&PathBuf::from("main.md"), "logos:unknown");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unknown intrinsic"));
}
#[test]
fn test_caching() {
let temp_dir = tempdir().unwrap();
let geo_path = temp_dir.path().join("geo.md");
fs::write(&geo_path, "content").unwrap();
let mut loader = Loader::new(temp_dir.path().to_path_buf());
let _ = loader.resolve(&temp_dir.path().join("main.md"), "file:./geo.md");
assert!(loader.loaded_modules().len() == 1);
}
#[test]
fn test_missing_file() {
let temp_dir = tempdir().unwrap();
let mut loader = Loader::new(temp_dir.path().to_path_buf());
let result = loader.resolve(&temp_dir.path().join("main.md"), "file:./nonexistent.md");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Failed to read"));
}
}