Skip to main content

aprender_rag/loader/
mod.rs

1//! Document loading abstraction for pluggable file format support.
2//!
3//! The [`DocumentLoader`] trait decouples file format handling from the
4//! RAG pipeline. Built-in loaders handle text (`.txt`, `.md`) and
5//! subtitle (`.srt`, `.vtt`) formats. Third parties can implement
6//! `DocumentLoader` for any format.
7//!
8//! The [`LoaderRegistry`] dispatches loading to the appropriate loader
9//! based on file extension, with support for sidecar subtitle files
10//! adjacent to media files.
11//!
12//! # Example
13//!
14//! ```rust
15//! use aprender_rag::loader::LoaderRegistry;
16//! use std::path::Path;
17//!
18//! let registry = LoaderRegistry::new();
19//! let extensions = registry.supported_extensions();
20//! assert!(extensions.contains(&"txt"));
21//! assert!(extensions.contains(&"srt"));
22//! ```
23
24#[cfg(feature = "ocr")]
25mod image;
26mod subtitle;
27mod text;
28
29#[cfg(feature = "ocr")]
30pub use image::ImageLoader;
31pub use subtitle::SubtitleLoader;
32pub use text::TextLoader;
33
34use crate::{Document, Error, Result};
35use std::path::Path;
36
37/// Abstraction for loading files of any format into Documents.
38///
39/// Implementors handle format detection, parsing, and conversion
40/// to the standard `Document` representation. A loader may support
41/// multiple file extensions.
42pub trait DocumentLoader: Send + Sync {
43    /// File extensions this loader handles (lowercase, without dot).
44    fn supported_extensions(&self) -> Vec<&str>;
45
46    /// Returns true if this loader can handle the given path.
47    ///
48    /// Default implementation checks the file extension against
49    /// [`supported_extensions()`](DocumentLoader::supported_extensions).
50    fn can_load(&self, path: &Path) -> bool {
51        path.extension()
52            .and_then(|ext| ext.to_str())
53            .map(|ext| {
54                let lower = ext.to_lowercase();
55                self.supported_extensions().iter().any(|s| *s == lower)
56            })
57            .unwrap_or(false)
58    }
59
60    /// Load a file and produce a Document.
61    ///
62    /// The returned Document should have:
63    /// - `content`: The extracted text
64    /// - `source`: The file path
65    /// - `title`: Derived from filename or embedded metadata
66    /// - `metadata`: Format-specific fields
67    fn load(&self, path: &Path) -> Result<Document>;
68}
69
70/// Registry that dispatches file loading to the appropriate [`DocumentLoader`].
71///
72/// Comes pre-loaded with [`TextLoader`] and [`SubtitleLoader`].
73/// Register additional loaders with [`register`](LoaderRegistry::register).
74pub struct LoaderRegistry {
75    loaders: Vec<Box<dyn DocumentLoader>>,
76}
77
78impl LoaderRegistry {
79    /// Create a registry with default loaders (text and subtitle).
80    #[must_use]
81    pub fn new() -> Self {
82        let mut registry = Self { loaders: Vec::new() };
83        registry.register(Box::new(TextLoader));
84        registry.register(Box::new(SubtitleLoader));
85        #[cfg(feature = "ocr")]
86        registry.register(Box::new(ImageLoader));
87        registry
88    }
89
90    /// Register a custom loader.
91    pub fn register(&mut self, loader: Box<dyn DocumentLoader>) {
92        self.loaders.push(loader);
93    }
94
95    /// Find the first loader that can handle the given path.
96    #[must_use]
97    pub fn loader_for(&self, path: &Path) -> Option<&dyn DocumentLoader> {
98        self.loaders.iter().find(|l| l.can_load(path)).map(|l| l.as_ref())
99    }
100
101    /// Load a document, selecting the appropriate loader automatically.
102    pub fn load(&self, path: &Path) -> Result<Document> {
103        let loader = self.loader_for(path).ok_or_else(|| {
104            Error::InvalidInput(format!("No loader registered for: {}", path.display()))
105        })?;
106        loader.load(path)
107    }
108
109    /// Check if a sidecar subtitle file exists for a media file.
110    ///
111    /// Returns the sidecar path if found (prefers `.srt` over `.vtt`).
112    #[must_use]
113    pub fn find_sidecar(media_path: &Path) -> Option<std::path::PathBuf> {
114        for ext in &["srt", "vtt"] {
115            let sidecar = media_path.with_extension(ext);
116            if sidecar.exists() {
117                return Some(sidecar);
118            }
119        }
120        None
121    }
122
123    /// All supported extensions across all registered loaders.
124    #[must_use]
125    pub fn supported_extensions(&self) -> Vec<&str> {
126        self.loaders.iter().flat_map(|l| l.supported_extensions()).collect()
127    }
128}
129
130impl Default for LoaderRegistry {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl std::fmt::Debug for LoaderRegistry {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        f.debug_struct("LoaderRegistry")
139            .field("loader_count", &self.loaders.len())
140            .field("extensions", &self.supported_extensions())
141            .finish()
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_registry_default_loaders() {
151        let registry = LoaderRegistry::new();
152        let exts = registry.supported_extensions();
153        assert!(exts.contains(&"txt"));
154        assert!(exts.contains(&"md"));
155        assert!(exts.contains(&"srt"));
156        assert!(exts.contains(&"vtt"));
157    }
158
159    #[test]
160    fn test_registry_loader_for_txt() {
161        let registry = LoaderRegistry::new();
162        assert!(registry.loader_for(Path::new("file.txt")).is_some());
163        assert!(registry.loader_for(Path::new("file.TXT")).is_some());
164    }
165
166    #[test]
167    fn test_registry_loader_for_srt() {
168        let registry = LoaderRegistry::new();
169        assert!(registry.loader_for(Path::new("file.srt")).is_some());
170    }
171
172    #[test]
173    fn test_registry_no_loader_for_unknown() {
174        let registry = LoaderRegistry::new();
175        assert!(registry.loader_for(Path::new("file.xyz")).is_none());
176    }
177
178    #[test]
179    fn test_registry_load_missing_file() {
180        let registry = LoaderRegistry::new();
181        let result = registry.load(Path::new("/nonexistent/file.txt"));
182        assert!(result.is_err());
183    }
184
185    #[test]
186    fn test_registry_load_unsupported_format() {
187        let registry = LoaderRegistry::new();
188        let result = registry.load(Path::new("file.mp4"));
189        assert!(result.is_err());
190    }
191
192    #[test]
193    fn test_find_sidecar_none() {
194        // No sidecar for a file in /tmp that doesn't exist
195        assert!(
196            LoaderRegistry::find_sidecar(Path::new("/tmp/nonexistent_video_12345.mp4")).is_none()
197        );
198    }
199
200    #[test]
201    fn test_registry_custom_loader() {
202        struct DummyLoader;
203        impl DocumentLoader for DummyLoader {
204            fn supported_extensions(&self) -> Vec<&str> {
205                vec!["xyz"]
206            }
207            fn load(&self, path: &Path) -> Result<Document> {
208                Ok(Document::new("dummy").with_source(path.to_string_lossy()))
209            }
210        }
211
212        let mut registry = LoaderRegistry::new();
213        registry.register(Box::new(DummyLoader));
214        assert!(registry.loader_for(Path::new("test.xyz")).is_some());
215    }
216
217    #[test]
218    fn test_registry_debug() {
219        let registry = LoaderRegistry::new();
220        let debug = format!("{registry:?}");
221        assert!(debug.contains("LoaderRegistry"));
222        assert!(debug.contains("loader_count"));
223    }
224
225    #[test]
226    fn test_registry_default() {
227        let registry = LoaderRegistry::default();
228        assert!(!registry.supported_extensions().is_empty());
229    }
230
231    #[test]
232    fn test_find_sidecar_srt_preferred() {
233        // Create temp files to test sidecar detection
234        let dir = std::env::temp_dir().join("trueno_rag_test_sidecar");
235        let _ = std::fs::create_dir_all(&dir);
236        let video = dir.join("lecture.mp4");
237        let srt = dir.join("lecture.srt");
238        let vtt = dir.join("lecture.vtt");
239        std::fs::write(&video, b"").unwrap();
240        std::fs::write(&srt, b"").unwrap();
241        std::fs::write(&vtt, b"").unwrap();
242
243        let found = LoaderRegistry::find_sidecar(&video);
244        assert!(found.is_some());
245        // SRT is preferred over VTT
246        assert_eq!(found.unwrap().extension().unwrap(), "srt");
247
248        // Cleanup
249        let _ = std::fs::remove_dir_all(&dir);
250    }
251
252    #[test]
253    fn test_can_load_no_extension() {
254        let loader = TextLoader;
255        assert!(!loader.can_load(Path::new("Makefile")));
256    }
257
258    #[test]
259    fn test_load_real_txt_file() {
260        let dir = std::env::temp_dir().join("trueno_rag_test_load_txt");
261        let _ = std::fs::create_dir_all(&dir);
262        let file = dir.join("test.txt");
263        std::fs::write(&file, "Hello from test file.").unwrap();
264
265        let registry = LoaderRegistry::new();
266        let doc = registry.load(&file).unwrap();
267        assert_eq!(doc.content, "Hello from test file.");
268        assert!(doc.title.is_some());
269
270        let _ = std::fs::remove_dir_all(&dir);
271    }
272
273    #[test]
274    fn test_load_real_srt_file() {
275        let dir = std::env::temp_dir().join("trueno_rag_test_load_srt");
276        let _ = std::fs::create_dir_all(&dir);
277        let file = dir.join("test.srt");
278        std::fs::write(&file, "1\n00:00:01,000 --> 00:00:04,500\nHello from subtitle.\n").unwrap();
279
280        let registry = LoaderRegistry::new();
281        let doc = registry.load(&file).unwrap();
282        assert!(doc.content.contains("Hello from subtitle"));
283        assert!(doc.metadata.contains_key("subtitle_cues"));
284
285        let _ = std::fs::remove_dir_all(&dir);
286    }
287}