Skip to main content

serializer/llm/
cache_generator.rs

1//! Cache Generator for DX Serializer
2//!
3//! Automatically generates LLM and Machine format cache files from Human format sources.
4//! Cache files are stored in `.dx/cache` with path preservation.
5//!
6//! ## Cache Structure
7//!
8//! ```text
9//! .dx/cache/
10//! ├── llm/
11//! │   ├── config.dx.llm
12//! │   └── subdir/
13//! │       └── data.dx.llm
14//! └── machine/
15//!     ├── config.dx.bin
16//!     └── subdir/
17//!         └── data.dx.bin
18//! ```
19
20use crate::llm::convert::{ConvertError, document_to_llm, document_to_machine};
21use crate::llm::human_parser::HumanParser;
22use crate::llm::types::DxDocument;
23use std::fs;
24use std::io;
25use std::path::{Path, PathBuf};
26use thiserror::Error;
27
28/// Cache generation errors
29#[derive(Debug, Error)]
30pub enum CacheError {
31    /// Filesystem or stream error while reading or writing cache files.
32    #[error("IO error: {0}")]
33    Io(#[from] io::Error),
34
35    /// Human-format parse failure before cache generation.
36    #[error("Parse error: {0}")]
37    Parse(String),
38
39    /// Format conversion failure while creating cache outputs.
40    #[error("Conversion error: {0}")]
41    Convert(#[from] ConvertError),
42
43    /// Source or destination path failed validation.
44    #[error("Invalid path: {0}")]
45    InvalidPath(String),
46
47    /// Cache directory could not be created.
48    #[error("Cache directory creation failed: {0}")]
49    DirectoryCreation(String),
50}
51
52/// Configuration for cache generation
53#[derive(Debug, Clone)]
54pub struct CacheConfig {
55    /// Root directory for cache files (default: .dx/cache)
56    pub cache_root: PathBuf,
57    /// Generate LLM format cache files
58    pub generate_llm: bool,
59    /// Generate Machine format cache files
60    pub generate_machine: bool,
61    /// Use atomic writes (temp file + rename)
62    pub atomic_writes: bool,
63}
64
65impl Default for CacheConfig {
66    fn default() -> Self {
67        Self {
68            cache_root: PathBuf::from(".dx/cache"),
69            generate_llm: true,
70            generate_machine: true,
71            atomic_writes: true,
72        }
73    }
74}
75
76impl CacheConfig {
77    /// Create a new config with default settings
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Set the cache root directory
83    pub fn with_cache_root(mut self, root: impl Into<PathBuf>) -> Self {
84        self.cache_root = root.into();
85        self
86    }
87
88    /// Set whether to generate LLM format
89    pub fn with_llm(mut self, generate: bool) -> Self {
90        self.generate_llm = generate;
91        self
92    }
93
94    /// Set whether to generate Machine format
95    pub fn with_machine(mut self, generate: bool) -> Self {
96        self.generate_machine = generate;
97        self
98    }
99
100    /// Set whether to use atomic writes
101    pub fn with_atomic_writes(mut self, atomic: bool) -> Self {
102        self.atomic_writes = atomic;
103        self
104    }
105}
106
107/// Cache generator for DX documents
108pub struct CacheGenerator {
109    config: CacheConfig,
110    parser: HumanParser,
111}
112
113impl CacheGenerator {
114    /// Create a new cache generator with default config
115    pub fn new() -> Self {
116        Self {
117            config: CacheConfig::default(),
118            parser: HumanParser::new(),
119        }
120    }
121
122    /// Create a cache generator with custom config
123    pub fn with_config(config: CacheConfig) -> Self {
124        Self {
125            config,
126            parser: HumanParser::new(),
127        }
128    }
129
130    /// Map a source path to cache paths
131    ///
132    /// Preserves the relative path structure in the cache directory.
133    /// Example: `src/config/data.dx` -> `.dx/cache/llm/src/config/data.dx.llm`
134    pub fn map_path_to_cache(&self, source_path: &Path, base_path: &Path) -> CachePaths {
135        // Get relative path from base
136        let relative = source_path.strip_prefix(base_path).unwrap_or(source_path);
137
138        // Normalize path separators
139        let relative_str = relative.to_string_lossy().replace('\\', "/");
140
141        // Build cache paths
142        let llm_path = self
143            .config
144            .cache_root
145            .join("llm")
146            .join(&relative_str)
147            .with_extension("dx.llm");
148
149        let machine_path = self
150            .config
151            .cache_root
152            .join("machine")
153            .join(&relative_str)
154            .with_extension("dx.bin");
155
156        CachePaths {
157            source: source_path.to_path_buf(),
158            llm: llm_path,
159            machine: machine_path,
160        }
161    }
162
163    /// Generate cache files from a Human format source file
164    pub fn generate(
165        &self,
166        source_path: &Path,
167        base_path: &Path,
168    ) -> Result<CacheResult, CacheError> {
169        // Read source file
170        let content = fs::read_to_string(source_path)?;
171
172        // Parse to document
173        let doc = self
174            .parser
175            .parse(&content)
176            .map_err(|e| CacheError::Parse(e.to_string()))?;
177
178        // Generate cache files
179        self.generate_from_document(&doc, source_path, base_path)
180    }
181
182    /// Generate cache files from a DxDocument
183    pub fn generate_from_document(
184        &self,
185        doc: &DxDocument,
186        source_path: &Path,
187        base_path: &Path,
188    ) -> Result<CacheResult, CacheError> {
189        let paths = self.map_path_to_cache(source_path, base_path);
190        let mut result = CacheResult {
191            paths: paths.clone(),
192            llm_generated: false,
193            machine_generated: false,
194        };
195
196        // Generate LLM format
197        if self.config.generate_llm {
198            let llm_content = document_to_llm(doc);
199            self.write_cache_file(&paths.llm, llm_content.as_bytes())?;
200            result.llm_generated = true;
201        }
202
203        // Generate Machine format
204        if self.config.generate_machine {
205            let machine_content = document_to_machine(doc);
206            self.write_cache_file(&paths.machine, &machine_content.data)?;
207            result.machine_generated = true;
208        }
209
210        Ok(result)
211    }
212
213    /// Write cache file with optional atomic write
214    fn write_cache_file(&self, path: &Path, content: &[u8]) -> Result<(), CacheError> {
215        // Ensure parent directory exists
216        if let Some(parent) = path.parent() {
217            fs::create_dir_all(parent).map_err(|e| {
218                CacheError::DirectoryCreation(format!("{}: {}", parent.display(), e))
219            })?;
220        }
221
222        if self.config.atomic_writes {
223            // Write to temp file first, then rename
224            let temp_path = path.with_extension("tmp");
225            fs::write(&temp_path, content)?;
226            fs::rename(&temp_path, path)?;
227        } else {
228            fs::write(path, content)?;
229        }
230
231        Ok(())
232    }
233
234    /// Get the config
235    pub fn config(&self) -> &CacheConfig {
236        &self.config
237    }
238}
239
240impl Default for CacheGenerator {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246/// Paths for cache files
247#[derive(Debug, Clone)]
248pub struct CachePaths {
249    /// Original source path
250    pub source: PathBuf,
251    /// LLM format cache path
252    pub llm: PathBuf,
253    /// Machine format cache path
254    pub machine: PathBuf,
255}
256
257/// Result of cache generation
258#[derive(Debug)]
259pub struct CacheResult {
260    /// Cache paths
261    pub paths: CachePaths,
262    /// Whether LLM format was generated
263    pub llm_generated: bool,
264    /// Whether Machine format was generated
265    pub machine_generated: bool,
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use std::path::Path;
272
273    #[test]
274    fn test_cache_config_default() {
275        let config = CacheConfig::default();
276        assert_eq!(config.cache_root, PathBuf::from(".dx/cache"));
277        assert!(config.generate_llm);
278        assert!(config.generate_machine);
279        assert!(config.atomic_writes);
280    }
281
282    #[test]
283    fn test_cache_config_builder() {
284        let config = CacheConfig::new()
285            .with_cache_root("/custom/cache")
286            .with_llm(false)
287            .with_machine(true)
288            .with_atomic_writes(false);
289
290        assert_eq!(config.cache_root, PathBuf::from("/custom/cache"));
291        assert!(!config.generate_llm);
292        assert!(config.generate_machine);
293        assert!(!config.atomic_writes);
294    }
295
296    #[test]
297    fn test_map_path_to_cache_simple() {
298        let generator = CacheGenerator::new();
299        let source = Path::new("config.dx");
300        let base = Path::new(".");
301
302        let paths = generator.map_path_to_cache(source, base);
303
304        assert_eq!(paths.source, PathBuf::from("config.dx"));
305        assert!(paths.llm.to_string_lossy().contains("llm"));
306        assert!(paths.llm.to_string_lossy().contains("config.dx.llm"));
307        assert!(paths.machine.to_string_lossy().contains("machine"));
308        assert!(paths.machine.to_string_lossy().contains("config.dx.bin"));
309    }
310
311    #[test]
312    fn test_map_path_to_cache_nested() {
313        let generator = CacheGenerator::new();
314        let source = Path::new("src/config/data.dx");
315        let base = Path::new(".");
316
317        let paths = generator.map_path_to_cache(source, base);
318
319        // Should preserve directory structure
320        let llm_str = paths.llm.to_string_lossy();
321        assert!(llm_str.contains("src") || llm_str.contains("config"));
322        assert!(llm_str.contains("data.dx.llm"));
323    }
324
325    #[test]
326    fn test_map_path_to_cache_with_base() {
327        let generator = CacheGenerator::new();
328        let source = Path::new("/project/src/config/data.dx");
329        let base = Path::new("/project");
330
331        let paths = generator.map_path_to_cache(source, base);
332
333        // Should strip base path
334        let llm_str = paths.llm.to_string_lossy();
335        assert!(!llm_str.contains("project") || llm_str.contains("src"));
336    }
337
338    #[test]
339    fn test_cache_generator_from_document() {
340        use crate::llm::types::{DxDocument, DxLlmValue};
341        use std::env;
342
343        // Use a temp directory in the target folder
344        let temp_dir = env::temp_dir().join("dx_cache_test_1");
345        let _ = fs::remove_dir_all(&temp_dir); // Clean up any previous run
346
347        let config = CacheConfig::new()
348            .with_cache_root(temp_dir.join("cache"))
349            .with_atomic_writes(false);
350
351        let generator = CacheGenerator::with_config(config);
352
353        // Create a simple document
354        let mut doc = DxDocument::new();
355        doc.context
356            .insert("nm".to_string(), DxLlmValue::Str("Test".to_string()));
357
358        let source = Path::new("test.dx");
359        let base = Path::new(".");
360
361        let result = generator
362            .generate_from_document(&doc, source, base)
363            .unwrap();
364
365        assert!(result.llm_generated);
366        assert!(result.machine_generated);
367        assert!(result.paths.llm.exists());
368        assert!(result.paths.machine.exists());
369
370        // Verify LLM content
371        let llm_content = fs::read_to_string(&result.paths.llm).unwrap();
372        assert!(llm_content.contains("nm") || llm_content.contains("Test"));
373
374        // Clean up
375        let _ = fs::remove_dir_all(&temp_dir);
376    }
377
378    #[test]
379    fn test_cache_generator_llm_only() {
380        use crate::llm::types::{DxDocument, DxLlmValue};
381        use std::env;
382
383        let temp_dir = env::temp_dir().join("dx_cache_test_2");
384        let _ = fs::remove_dir_all(&temp_dir);
385
386        let config = CacheConfig::new()
387            .with_cache_root(temp_dir.join("cache"))
388            .with_llm(true)
389            .with_machine(false);
390
391        let generator = CacheGenerator::with_config(config);
392
393        let mut doc = DxDocument::new();
394        doc.context
395            .insert("nm".to_string(), DxLlmValue::Str("Test".to_string()));
396
397        let source = Path::new("test.dx");
398        let base = Path::new(".");
399
400        let result = generator
401            .generate_from_document(&doc, source, base)
402            .unwrap();
403
404        assert!(result.llm_generated);
405        assert!(!result.machine_generated);
406        assert!(result.paths.llm.exists());
407        assert!(!result.paths.machine.exists());
408
409        // Clean up
410        let _ = fs::remove_dir_all(&temp_dir);
411    }
412
413    #[test]
414    fn test_cache_paths_structure() {
415        let paths = CachePaths {
416            source: PathBuf::from("src/data.dx"),
417            llm: PathBuf::from(".dx/cache/llm/src/data.dx.llm"),
418            machine: PathBuf::from(".dx/cache/machine/src/data.dx.bin"),
419        };
420
421        assert_eq!(paths.source.file_name().unwrap(), "data.dx");
422        assert!(paths.llm.extension().unwrap() == "llm");
423        assert!(paths.machine.extension().unwrap() == "bin");
424    }
425}