Skip to main content

cuenv_codegen/
generator.rs

1//! File generation engine
2//!
3//! This module handles the core file generation logic, including:
4//! - Writing files based on mode (managed vs scaffold)
5//! - Formatting generated code
6//! - Checking if files need updates
7
8use crate::codegen::{Codegen, FileMode};
9use crate::formatter::Formatter;
10use crate::{CodegenError, Result};
11use std::path::{Path, PathBuf};
12
13/// Generated file information
14#[derive(Debug, Clone)]
15pub struct GeneratedFile {
16    /// Path where the file was/will be written
17    pub path: PathBuf,
18    /// Final content (after formatting)
19    pub content: String,
20    /// Generation mode
21    pub mode: FileMode,
22    /// Programming language
23    pub language: String,
24}
25
26/// Options for file generation
27#[derive(Debug, Clone)]
28pub struct GenerateOptions {
29    /// Output directory for generated files
30    pub output_dir: PathBuf,
31    /// Check mode: don't write files, just check if they would change
32    pub check: bool,
33    /// Show diffs for changed files
34    pub diff: bool,
35}
36
37impl Default for GenerateOptions {
38    fn default() -> Self {
39        Self {
40            output_dir: PathBuf::from("."),
41            check: false,
42            diff: false,
43        }
44    }
45}
46
47/// File generator
48#[derive(Debug)]
49pub struct Generator {
50    codegen: Codegen,
51    formatter: Formatter,
52}
53
54impl Generator {
55    /// Create a new generator from a codegen configuration
56    #[must_use]
57    pub fn new(codegen: Codegen) -> Self {
58        Self {
59            codegen,
60            formatter: Formatter::new(),
61        }
62    }
63
64    /// Generate all files from the codegen configuration
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if file writing or formatting fails
69    pub fn generate(&self, options: &GenerateOptions) -> Result<Vec<GeneratedFile>> {
70        let mut generated_files = Vec::new();
71
72        for (file_path, file_def) in self.codegen.files() {
73            let output_path = options.output_dir.join(file_path);
74
75            // Format the content
76            let formatted_content =
77                self.formatter
78                    .format(&file_def.content, &file_def.language, &file_def.format)?;
79
80            let generated = GeneratedFile {
81                path: output_path.clone(),
82                content: formatted_content.clone(),
83                mode: file_def.mode,
84                language: file_def.language.clone(),
85            };
86
87            // Handle different modes
88            match file_def.mode {
89                FileMode::Managed => {
90                    if options.check {
91                        self.check_file(&output_path, &formatted_content)?;
92                    } else {
93                        self.write_file(&output_path, &formatted_content)?;
94                    }
95                }
96                FileMode::Scaffold => {
97                    if output_path.exists() {
98                        tracing::info!("Skipping {} (scaffold mode, file exists)", file_path);
99                    } else if options.check {
100                        return Err(CodegenError::Generation(format!(
101                            "Missing scaffold file: {file_path}"
102                        )));
103                    } else {
104                        self.write_file(&output_path, &formatted_content)?;
105                    }
106                }
107            }
108
109            generated_files.push(generated);
110        }
111
112        Ok(generated_files)
113    }
114
115    /// Write a file to disk
116    #[allow(clippy::unused_self)] // Will use self for write options in future
117    fn write_file(&self, path: &Path, content: &str) -> Result<()> {
118        // Create parent directories if they don't exist
119        if let Some(parent) = path.parent() {
120            std::fs::create_dir_all(parent)?;
121        }
122
123        std::fs::write(path, content)?;
124        tracing::info!("Generated: {}", path.display());
125
126        Ok(())
127    }
128
129    /// Check if a file would be modified
130    #[allow(clippy::unused_self)] // Will use self for check options in future
131    fn check_file(&self, path: &Path, expected_content: &str) -> Result<()> {
132        if !path.exists() {
133            return Err(CodegenError::Generation(format!(
134                "Missing managed file: {}",
135                path.display()
136            )));
137        }
138
139        let actual_content = std::fs::read_to_string(path)?;
140
141        if actual_content != expected_content {
142            return Err(CodegenError::Generation(format!(
143                "File would be modified: {}",
144                path.display()
145            )));
146        }
147
148        Ok(())
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::codegen::{CodegenData, FormatConfig, ProjectFileDefinition};
156    use std::collections::HashMap;
157    use tempfile::TempDir;
158
159    fn create_test_codegen() -> Codegen {
160        let mut files = HashMap::new();
161        files.insert(
162            "test.json".to_string(),
163            ProjectFileDefinition {
164                content: r#"{"name":"test"}"#.to_string(),
165                language: "json".to_string(),
166                mode: FileMode::Managed,
167                format: FormatConfig::default(),
168                gitignore: false,
169            },
170        );
171
172        let data = CodegenData {
173            files,
174            context: serde_json::Value::Null,
175        };
176
177        Codegen {
178            data,
179            source_path: PathBuf::from("test.cue"),
180        }
181    }
182
183    fn create_scaffold_codegen() -> Codegen {
184        let mut files = HashMap::new();
185        files.insert(
186            "scaffold.txt".to_string(),
187            ProjectFileDefinition {
188                content: "scaffold content".to_string(),
189                language: "text".to_string(),
190                mode: FileMode::Scaffold,
191                format: FormatConfig::default(),
192                gitignore: false,
193            },
194        );
195
196        let data = CodegenData {
197            files,
198            context: serde_json::Value::Null,
199        };
200
201        Codegen {
202            data,
203            source_path: PathBuf::from("scaffold.cue"),
204        }
205    }
206
207    #[test]
208    fn test_generate_options_default() {
209        let options = GenerateOptions::default();
210        assert_eq!(options.output_dir, PathBuf::from("."));
211        assert!(!options.check);
212        assert!(!options.diff);
213    }
214
215    #[test]
216    fn test_generated_file_clone() {
217        let file = GeneratedFile {
218            path: PathBuf::from("test.rs"),
219            content: "fn main() {}".to_string(),
220            mode: FileMode::Managed,
221            language: "rust".to_string(),
222        };
223        let cloned = file.clone();
224        assert_eq!(cloned.path, file.path);
225        assert_eq!(cloned.content, file.content);
226        assert_eq!(cloned.mode, file.mode);
227        assert_eq!(cloned.language, file.language);
228    }
229
230    #[test]
231    fn test_generated_file_debug() {
232        let file = GeneratedFile {
233            path: PathBuf::from("test.rs"),
234            content: "fn main() {}".to_string(),
235            mode: FileMode::Managed,
236            language: "rust".to_string(),
237        };
238        let debug = format!("{file:?}");
239        assert!(debug.contains("test.rs"));
240        assert!(debug.contains("rust"));
241    }
242
243    #[test]
244    fn test_generator_new() {
245        let codegen = create_test_codegen();
246        let generator = Generator::new(codegen);
247        assert!(generator.codegen.files().contains_key("test.json"));
248    }
249
250    #[test]
251    fn test_generate_managed_file() {
252        let codegen = create_test_codegen();
253        let generator = Generator::new(codegen);
254
255        let temp_dir = TempDir::new().unwrap();
256        let options = GenerateOptions {
257            output_dir: temp_dir.path().to_path_buf(),
258            check: false,
259            diff: false,
260        };
261
262        let result = generator.generate(&options);
263        assert!(result.is_ok());
264
265        let generated = result.unwrap();
266        assert_eq!(generated.len(), 1);
267        assert_eq!(generated[0].mode, FileMode::Managed);
268
269        let file_path = temp_dir.path().join("test.json");
270        assert!(file_path.exists());
271    }
272
273    #[test]
274    fn test_generate_scaffold_creates_new_file() {
275        let codegen = create_scaffold_codegen();
276        let generator = Generator::new(codegen);
277
278        let temp_dir = TempDir::new().unwrap();
279        let options = GenerateOptions {
280            output_dir: temp_dir.path().to_path_buf(),
281            check: false,
282            diff: false,
283        };
284
285        let result = generator.generate(&options);
286        assert!(result.is_ok());
287
288        let file_path = temp_dir.path().join("scaffold.txt");
289        assert!(file_path.exists());
290        let content = std::fs::read_to_string(file_path).unwrap();
291        assert_eq!(content, "scaffold content");
292    }
293
294    #[test]
295    fn test_generate_scaffold_skips_existing_file() {
296        let codegen = create_scaffold_codegen();
297        let generator = Generator::new(codegen);
298
299        let temp_dir = TempDir::new().unwrap();
300        let file_path = temp_dir.path().join("scaffold.txt");
301        std::fs::write(&file_path, "existing content").unwrap();
302
303        let options = GenerateOptions {
304            output_dir: temp_dir.path().to_path_buf(),
305            check: false,
306            diff: false,
307        };
308
309        let result = generator.generate(&options);
310        assert!(result.is_ok());
311
312        // File should not be overwritten
313        let content = std::fs::read_to_string(file_path).unwrap();
314        assert_eq!(content, "existing content");
315    }
316
317    #[test]
318    fn test_generate_check_mode_missing_managed_file() {
319        let codegen = create_test_codegen();
320        let generator = Generator::new(codegen);
321
322        let temp_dir = TempDir::new().unwrap();
323        let options = GenerateOptions {
324            output_dir: temp_dir.path().to_path_buf(),
325            check: true,
326            diff: false,
327        };
328
329        let result = generator.generate(&options);
330        assert!(result.is_err());
331        let err = result.unwrap_err();
332        assert!(err.to_string().contains("Missing managed file"));
333    }
334
335    #[test]
336    fn test_generate_check_mode_file_would_be_modified() {
337        let codegen = create_test_codegen();
338        let generator = Generator::new(codegen);
339
340        let temp_dir = TempDir::new().unwrap();
341        let file_path = temp_dir.path().join("test.json");
342        std::fs::write(&file_path, "different content").unwrap();
343
344        let options = GenerateOptions {
345            output_dir: temp_dir.path().to_path_buf(),
346            check: true,
347            diff: false,
348        };
349
350        let result = generator.generate(&options);
351        assert!(result.is_err());
352        let err = result.unwrap_err();
353        assert!(err.to_string().contains("would be modified"));
354    }
355
356    #[test]
357    fn test_generate_check_mode_file_matches() {
358        let codegen = create_test_codegen();
359        let generator = Generator::new(codegen);
360
361        let temp_dir = TempDir::new().unwrap();
362
363        // First generate the file
364        let options = GenerateOptions {
365            output_dir: temp_dir.path().to_path_buf(),
366            check: false,
367            diff: false,
368        };
369        generator.generate(&options).unwrap();
370
371        // Now check should pass
372        let check_options = GenerateOptions {
373            output_dir: temp_dir.path().to_path_buf(),
374            check: true,
375            diff: false,
376        };
377        let result = generator.generate(&check_options);
378        assert!(result.is_ok());
379    }
380
381    #[test]
382    fn test_generate_check_mode_missing_scaffold_file() {
383        let codegen = create_scaffold_codegen();
384        let generator = Generator::new(codegen);
385
386        let temp_dir = TempDir::new().unwrap();
387        let options = GenerateOptions {
388            output_dir: temp_dir.path().to_path_buf(),
389            check: true,
390            diff: false,
391        };
392
393        let result = generator.generate(&options);
394        assert!(result.is_err());
395        let err = result.unwrap_err();
396        assert!(err.to_string().contains("Missing scaffold file"));
397    }
398
399    #[test]
400    fn test_generate_creates_nested_directories() {
401        let mut files = HashMap::new();
402        files.insert(
403            "deep/nested/path/file.txt".to_string(),
404            ProjectFileDefinition {
405                content: "nested content".to_string(),
406                language: "text".to_string(),
407                mode: FileMode::Managed,
408                format: FormatConfig::default(),
409                gitignore: false,
410            },
411        );
412
413        let codegen = Codegen {
414            data: CodegenData {
415                files,
416                context: serde_json::Value::Null,
417            },
418            source_path: PathBuf::from("test.cue"),
419        };
420        let generator = Generator::new(codegen);
421
422        let temp_dir = TempDir::new().unwrap();
423        let options = GenerateOptions {
424            output_dir: temp_dir.path().to_path_buf(),
425            check: false,
426            diff: false,
427        };
428
429        let result = generator.generate(&options);
430        assert!(result.is_ok());
431
432        let file_path = temp_dir.path().join("deep/nested/path/file.txt");
433        assert!(file_path.exists());
434        let content = std::fs::read_to_string(file_path).unwrap();
435        assert_eq!(content, "nested content");
436    }
437
438    #[test]
439    fn test_generate_multiple_files() {
440        let mut files = HashMap::new();
441        files.insert(
442            "file1.txt".to_string(),
443            ProjectFileDefinition {
444                content: "content 1".to_string(),
445                language: "text".to_string(),
446                mode: FileMode::Managed,
447                format: FormatConfig::default(),
448                gitignore: false,
449            },
450        );
451        files.insert(
452            "file2.txt".to_string(),
453            ProjectFileDefinition {
454                content: "content 2".to_string(),
455                language: "text".to_string(),
456                mode: FileMode::Scaffold,
457                format: FormatConfig::default(),
458                gitignore: false,
459            },
460        );
461
462        let codegen = Codegen {
463            data: CodegenData {
464                files,
465                context: serde_json::Value::Null,
466            },
467            source_path: PathBuf::from("test.cue"),
468        };
469        let generator = Generator::new(codegen);
470
471        let temp_dir = TempDir::new().unwrap();
472        let options = GenerateOptions {
473            output_dir: temp_dir.path().to_path_buf(),
474            check: false,
475            diff: false,
476        };
477
478        let result = generator.generate(&options);
479        assert!(result.is_ok());
480
481        let generated = result.unwrap();
482        assert_eq!(generated.len(), 2);
483
484        assert!(temp_dir.path().join("file1.txt").exists());
485        assert!(temp_dir.path().join("file2.txt").exists());
486    }
487}