logicaffeine_compile/
loader.rs

1//! Module loader for multi-file LOGOS projects.
2//!
3//! Handles resolution and loading of module sources from various URI schemes,
4//! with caching to prevent duplicate loading.
5//!
6//! # Supported URI Schemes
7//!
8//! | Scheme | Example | Description |
9//! |--------|---------|-------------|
10//! | `file:` | `file:./geometry.md` | Local filesystem (relative) |
11//! | `logos:` | `logos:std` | Built-in standard library |
12//! | (none) | `geometry.md` | Defaults to `file:` scheme |
13//!
14//! # Security
15//!
16//! The loader prevents path traversal attacks by checking that resolved
17//! paths remain within the project root directory.
18//!
19//! # Caching
20//!
21//! Modules are cached by their normalized URI. The same module loaded from
22//! different base paths will be cached separately.
23//!
24//! # Example
25//!
26//! ```ignore
27//! let mut loader = Loader::new(project_root);
28//! let source = loader.resolve(Path::new("main.md"), "file:./lib/math.md")?;
29//! println!("Loaded: {}", source.path.display());
30//! ```
31
32use std::collections::HashMap;
33use std::fs;
34use std::path::{Path, PathBuf};
35
36/// A loaded module's source content and metadata.
37#[derive(Debug, Clone)]
38pub struct ModuleSource {
39    /// The source content of the module
40    pub content: String,
41    /// The resolved path (for error reporting and relative resolution)
42    pub path: PathBuf,
43}
44
45/// Module loader that handles multiple URI schemes.
46///
47/// Caches loaded modules to prevent duplicate loading and supports
48/// cycle detection through the cache.
49pub struct Loader {
50    /// Cache of loaded modules (URI -> ModuleSource)
51    cache: HashMap<String, ModuleSource>,
52    /// Root directory of the project (for relative path resolution)
53    root_path: PathBuf,
54}
55
56impl Loader {
57    /// Creates a new Loader with the given root path.
58    pub fn new(root_path: PathBuf) -> Self {
59        Loader {
60            cache: HashMap::new(),
61            root_path,
62        }
63    }
64
65    /// Resolves a URI to a module source.
66    ///
67    /// Supports:
68    /// - `file:./path.md` - Local filesystem (relative to base_path)
69    /// - `logos:std` - Built-in standard library
70    /// - `logos:core` - Built-in core types
71    pub fn resolve(&mut self, base_path: &Path, uri: &str) -> Result<&ModuleSource, String> {
72        // Normalize the URI for caching
73        let cache_key = self.normalize_uri(base_path, uri)?;
74
75        // Check cache first
76        if self.cache.contains_key(&cache_key) {
77            return Ok(&self.cache[&cache_key]);
78        }
79
80        // Load based on scheme
81        let source = if uri.starts_with("file:") {
82            self.load_file(base_path, uri)?
83        } else if uri.starts_with("logos:") {
84            self.load_intrinsic(uri)?
85        } else if uri.starts_with("https://") || uri.starts_with("http://") {
86            // Remote loading not supported in base loader
87            return Err(format!(
88                "Remote module loading not supported for '{}'. \
89                 Use the CLI's 'logos fetch' command to download dependencies locally.",
90                uri
91            ));
92        } else {
93            // Default to file: scheme if no scheme provided
94            self.load_file(base_path, &format!("file:{}", uri))?
95        };
96
97        // Cache and return
98        self.cache.insert(cache_key.clone(), source);
99        Ok(&self.cache[&cache_key])
100    }
101
102    /// Normalizes a URI for consistent caching.
103    fn normalize_uri(&self, base_path: &Path, uri: &str) -> Result<String, String> {
104        if uri.starts_with("file:") {
105            let path_str = uri.trim_start_matches("file:");
106            let base_dir = base_path.parent().unwrap_or(&self.root_path);
107            let resolved = base_dir.join(path_str);
108            Ok(format!("file:{}", resolved.display()))
109        } else {
110            Ok(uri.to_string())
111        }
112    }
113
114    /// Loads a module from the local filesystem.
115    fn load_file(&self, base_path: &Path, uri: &str) -> Result<ModuleSource, String> {
116        let path_str = uri.trim_start_matches("file:");
117
118        // Resolve relative to the base file's directory
119        let base_dir = base_path.parent().unwrap_or(&self.root_path);
120        let resolved_path = base_dir.join(path_str);
121
122        // Security: Check that we're not escaping the root path
123        let canonical_root = self.root_path.canonicalize()
124            .unwrap_or_else(|_| self.root_path.clone());
125
126        // Read the file
127        let content = fs::read_to_string(&resolved_path)
128            .map_err(|e| format!("Failed to read '{}': {}", resolved_path.display(), e))?;
129
130        // Check if escaping root (after we know the file exists)
131        if let Ok(canonical_path) = resolved_path.canonicalize() {
132            if !canonical_path.starts_with(&canonical_root) {
133                return Err(format!(
134                    "Security: Cannot load '{}' - path escapes project root",
135                    uri
136                ));
137            }
138        }
139
140        Ok(ModuleSource {
141            content,
142            path: resolved_path,
143        })
144    }
145
146    /// Loads a built-in module (embedded at compile time).
147    fn load_intrinsic(&self, uri: &str) -> Result<ModuleSource, String> {
148        let name = uri.trim_start_matches("logos:");
149
150        match name {
151            "std" => Ok(ModuleSource {
152                content: include_str!("../assets/std/std.md").to_string(),
153                path: PathBuf::from("logos:std"),
154            }),
155            "core" => Ok(ModuleSource {
156                content: include_str!("../assets/std/core.md").to_string(),
157                path: PathBuf::from("logos:core"),
158            }),
159            _ => Err(format!("Unknown intrinsic module: '{}'", uri)),
160        }
161    }
162
163    /// Checks if a module has already been loaded (for cycle detection).
164    pub fn is_loaded(&self, uri: &str) -> bool {
165        self.cache.contains_key(uri)
166    }
167
168    /// Returns all loaded module URIs (for debugging).
169    pub fn loaded_modules(&self) -> Vec<&str> {
170        self.cache.keys().map(|s| s.as_str()).collect()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use tempfile::tempdir;
178
179    #[test]
180    fn test_file_scheme_resolution() {
181        let temp_dir = tempdir().unwrap();
182        let geo_path = temp_dir.path().join("geo.md");
183        fs::write(&geo_path, "## Definition\nA Point has:\n    an x, which is Int.\n").unwrap();
184
185        let mut loader = Loader::new(temp_dir.path().to_path_buf());
186        let result = loader.resolve(&temp_dir.path().join("main.md"), "file:./geo.md");
187
188        assert!(result.is_ok(), "Should resolve file: scheme: {:?}", result);
189        assert!(result.unwrap().content.contains("Point"));
190    }
191
192    #[test]
193    fn test_logos_std_scheme() {
194        let mut loader = Loader::new(PathBuf::from("."));
195        let result = loader.resolve(&PathBuf::from("main.md"), "logos:std");
196
197        assert!(result.is_ok(), "Should resolve logos:std: {:?}", result);
198    }
199
200    #[test]
201    fn test_logos_core_scheme() {
202        let mut loader = Loader::new(PathBuf::from("."));
203        let result = loader.resolve(&PathBuf::from("main.md"), "logos:core");
204
205        assert!(result.is_ok(), "Should resolve logos:core: {:?}", result);
206    }
207
208    #[test]
209    fn test_unknown_intrinsic() {
210        let mut loader = Loader::new(PathBuf::from("."));
211        let result = loader.resolve(&PathBuf::from("main.md"), "logos:unknown");
212
213        assert!(result.is_err());
214        assert!(result.unwrap_err().contains("Unknown intrinsic"));
215    }
216
217    #[test]
218    fn test_caching() {
219        let temp_dir = tempdir().unwrap();
220        let geo_path = temp_dir.path().join("geo.md");
221        fs::write(&geo_path, "content").unwrap();
222
223        let mut loader = Loader::new(temp_dir.path().to_path_buf());
224
225        // First load
226        let _ = loader.resolve(&temp_dir.path().join("main.md"), "file:./geo.md");
227
228        // Should be cached now
229        assert!(loader.loaded_modules().len() == 1);
230    }
231
232    #[test]
233    fn test_missing_file() {
234        let temp_dir = tempdir().unwrap();
235        let mut loader = Loader::new(temp_dir.path().to_path_buf());
236
237        let result = loader.resolve(&temp_dir.path().join("main.md"), "file:./nonexistent.md");
238
239        assert!(result.is_err());
240        assert!(result.unwrap_err().contains("Failed to read"));
241    }
242}