Skip to main content

cuenv_codegen/
codegen.rs

1//! CUE Codegen loading and evaluation
2//!
3//! This module handles loading CUE codegen configurations and evaluating them to extract
4//! file definitions. A Codegen configuration is a CUE-based template that defines multiple
5//! files to generate for a project.
6
7use crate::{CodegenError, Result};
8use cuengine::ModuleEvalOptions;
9use cuenv_core::ModuleEvaluation;
10use cuenv_core::cue::discovery::find_cue_module_root;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15/// File generation mode
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum FileMode {
19    /// Always regenerate this file (managed by codegen)
20    #[default]
21    Managed,
22    /// Generate only if file doesn't exist (user owns this file)
23    Scaffold,
24}
25
26/// Format configuration for a code file
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct FormatConfig {
29    /// Indent style: "space" or "tab"
30    pub indent: String,
31    /// Indent size (number of spaces or tab width)
32    #[serde(rename = "indentSize")]
33    pub indent_size: Option<usize>,
34    /// Maximum line width
35    #[serde(rename = "lineWidth")]
36    pub line_width: Option<usize>,
37    /// Trailing comma style
38    #[serde(rename = "trailingComma")]
39    pub trailing_comma: Option<String>,
40    /// Use semicolons
41    pub semicolons: Option<bool>,
42    /// Quote style: "single" or "double"
43    pub quotes: Option<String>,
44}
45
46impl Default for FormatConfig {
47    fn default() -> Self {
48        Self {
49            indent: "space".to_string(),
50            indent_size: Some(2),
51            line_width: Some(100),
52            trailing_comma: None,
53            semicolons: None,
54            quotes: None,
55        }
56    }
57}
58
59/// A project file definition from the codegen configuration
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ProjectFileDefinition {
62    /// Content of the file
63    pub content: String,
64    /// Programming language of the file
65    pub language: String,
66    /// Generation mode (managed or scaffold)
67    #[serde(default)]
68    pub mode: FileMode,
69    /// Formatting configuration
70    #[serde(default)]
71    pub format: FormatConfig,
72    /// Whether to add this file path to .gitignore
73    #[serde(default)]
74    pub gitignore: bool,
75}
76
77/// CUE codegen data containing file definitions
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct CodegenData {
80    /// Map of file paths to their definitions
81    pub files: HashMap<String, ProjectFileDefinition>,
82    /// Optional context data
83    #[serde(default)]
84    pub context: serde_json::Value,
85}
86
87/// CUE Codegen loader and evaluator
88///
89/// A Codegen configuration is a CUE-based template that generates multiple project files.
90/// It defines the structure and content of files to be generated for a project.
91#[derive(Debug)]
92pub struct Codegen {
93    /// The codegen data containing file definitions
94    pub data: CodegenData,
95    /// Path to the source CUE file
96    pub source_path: PathBuf,
97}
98
99impl Codegen {
100    /// Load a codegen configuration from a CUE file
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if the file cannot be read or the CUE evaluation fails
105    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
106        let path = path.as_ref();
107
108        // For now, we'll use cuengine to evaluate the CUE file
109        // This is a placeholder - the actual implementation will use cuengine
110        let data = Self::evaluate_cue(path)?;
111
112        Ok(Self {
113            data,
114            source_path: path.to_path_buf(),
115        })
116    }
117
118    /// Get the file definitions from this codegen configuration
119    #[must_use]
120    pub fn files(&self) -> &HashMap<String, ProjectFileDefinition> {
121        &self.data.files
122    }
123
124    /// Get the context data
125    #[must_use]
126    pub fn context(&self) -> &serde_json::Value {
127        &self.data.context
128    }
129
130    /// Get the source path of this codegen configuration
131    #[must_use]
132    pub fn source_path(&self) -> &Path {
133        &self.source_path
134    }
135
136    /// Evaluate a CUE file and extract the codegen data
137    #[allow(clippy::too_many_lines)]
138    fn evaluate_cue(path: &Path) -> Result<CodegenData> {
139        // Verify the file exists
140        if !path.exists() {
141            return Err(CodegenError::Codegen(format!(
142                "Codegen file not found: {}",
143                path.display()
144            )));
145        }
146
147        // Determine the directory and package name from the path
148        let dir_path = path.parent().ok_or_else(|| {
149            CodegenError::Codegen("Invalid codegen path: no parent directory".to_string())
150        })?;
151
152        // Determine package name - try to infer from file content or use default
153        let package_name = Self::determine_package_name(path)?;
154
155        let target_path = dir_path
156            .canonicalize()
157            .map_err(|e| CodegenError::Codegen(format!("Failed to canonicalize path: {e}")))?;
158
159        // Find the module root
160        let module_root = find_cue_module_root(&target_path).ok_or_else(|| {
161            CodegenError::Codegen(format!(
162                "No CUE module found (looking for cue.mod/) starting from: {}",
163                target_path.display()
164            ))
165        })?;
166
167        // Use targeted evaluation (non-recursive) for the specific directory
168        let options = ModuleEvalOptions {
169            recursive: false,
170            target_dir: Some(target_path.to_string_lossy().to_string()),
171            ..Default::default()
172        };
173        let raw_result = cuengine::evaluate_module(&module_root, &package_name, Some(&options))
174            .map_err(|e| CodegenError::Codegen(format!("CUE evaluation failed: {e}")))?;
175
176        let module = ModuleEvaluation::from_raw(
177            module_root.clone(),
178            raw_result.instances,
179            raw_result.projects,
180            None, // codegen doesn't need dependsOn resolution
181        );
182
183        // Calculate relative path and get the instance
184        let relative_path = target_path.strip_prefix(&module_root).map_or_else(
185            |_| PathBuf::from("."),
186            |p| {
187                if p.as_os_str().is_empty() {
188                    PathBuf::from(".")
189                } else {
190                    p.to_path_buf()
191                }
192            },
193        );
194
195        let instance = module.get(&relative_path).ok_or_else(|| {
196            CodegenError::Codegen(format!(
197                "No CUE instance found at path: {} (relative: {})",
198                dir_path.display(),
199                relative_path.display()
200            ))
201        })?;
202
203        instance
204            .deserialize()
205            .map_err(|e| CodegenError::Codegen(format!("Failed to deserialize codegen data: {e}")))
206    }
207
208    /// Determine the CUE package name from a file
209    ///
210    /// Reads the first few lines of the file to find a `package` declaration.
211    /// Falls back to "codegen" if not found.
212    fn determine_package_name(path: &Path) -> Result<String> {
213        let content = std::fs::read_to_string(path)
214            .map_err(|e| CodegenError::Codegen(format!("Failed to read codegen file: {e}")))?;
215
216        // Look for package declaration in the first few lines
217        for line in content.lines().take(10) {
218            let trimmed = line.trim();
219            if trimmed.starts_with("package ") {
220                // Extract package name
221                let package_name = trimmed.strip_prefix("package ").unwrap_or("codegen").trim();
222                return Ok(package_name.to_string());
223            }
224        }
225
226        // Default to "codegen" if no package declaration found
227        Ok("codegen".to_string())
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_file_mode_default() {
237        assert_eq!(FileMode::default(), FileMode::Managed);
238    }
239
240    #[test]
241    fn test_file_mode_serde_managed() {
242        let json = r#""managed""#;
243        let mode: FileMode = serde_json::from_str(json).unwrap();
244        assert_eq!(mode, FileMode::Managed);
245        assert_eq!(serde_json::to_string(&mode).unwrap(), json);
246    }
247
248    #[test]
249    fn test_file_mode_serde_scaffold() {
250        let json = r#""scaffold""#;
251        let mode: FileMode = serde_json::from_str(json).unwrap();
252        assert_eq!(mode, FileMode::Scaffold);
253        assert_eq!(serde_json::to_string(&mode).unwrap(), json);
254    }
255
256    #[test]
257    fn test_file_mode_clone() {
258        let mode = FileMode::Managed;
259        let cloned = mode;
260        assert_eq!(mode, cloned);
261    }
262
263    #[test]
264    fn test_file_mode_copy() {
265        let mode = FileMode::Scaffold;
266        let copied = mode;
267        assert_eq!(mode, copied);
268    }
269
270    #[test]
271    fn test_format_config_default() {
272        let config = FormatConfig::default();
273        assert_eq!(config.indent, "space");
274        assert_eq!(config.indent_size, Some(2));
275        assert_eq!(config.line_width, Some(100));
276        assert!(config.trailing_comma.is_none());
277        assert!(config.semicolons.is_none());
278        assert!(config.quotes.is_none());
279    }
280
281    #[test]
282    fn test_format_config_clone() {
283        let config = FormatConfig {
284            indent: "tab".to_string(),
285            indent_size: Some(4),
286            line_width: Some(120),
287            trailing_comma: Some("all".to_string()),
288            semicolons: Some(true),
289            quotes: Some("single".to_string()),
290        };
291        let cloned = config.clone();
292        assert_eq!(cloned.indent, "tab");
293        assert_eq!(cloned.indent_size, Some(4));
294        assert_eq!(cloned.line_width, Some(120));
295        assert_eq!(cloned.trailing_comma, Some("all".to_string()));
296        assert_eq!(cloned.semicolons, Some(true));
297        assert_eq!(cloned.quotes, Some("single".to_string()));
298    }
299
300    #[test]
301    fn test_format_config_serde_roundtrip() {
302        let config = FormatConfig {
303            indent: "space".to_string(),
304            indent_size: Some(2),
305            line_width: Some(80),
306            trailing_comma: Some("es5".to_string()),
307            semicolons: Some(false),
308            quotes: Some("double".to_string()),
309        };
310        let json = serde_json::to_string(&config).unwrap();
311        let deserialized: FormatConfig = serde_json::from_str(&json).unwrap();
312        assert_eq!(config.indent, deserialized.indent);
313        assert_eq!(config.indent_size, deserialized.indent_size);
314        assert_eq!(config.quotes, deserialized.quotes);
315    }
316
317    #[test]
318    fn test_project_file_definition_serde() {
319        let def = ProjectFileDefinition {
320            content: "test content".to_string(),
321            language: "json".to_string(),
322            mode: FileMode::Scaffold,
323            format: FormatConfig::default(),
324            gitignore: true,
325        };
326        let json = serde_json::to_string(&def).unwrap();
327        let deserialized: ProjectFileDefinition = serde_json::from_str(&json).unwrap();
328        assert_eq!(deserialized.content, "test content");
329        assert_eq!(deserialized.language, "json");
330        assert_eq!(deserialized.mode, FileMode::Scaffold);
331        assert!(deserialized.gitignore);
332    }
333
334    #[test]
335    fn test_project_file_definition_defaults() {
336        // Test that serde default attributes work
337        let json = r#"{"content":"x","language":"rust"}"#;
338        let def: ProjectFileDefinition = serde_json::from_str(json).unwrap();
339        assert_eq!(def.mode, FileMode::Managed); // default
340        assert!(!def.gitignore); // default
341    }
342
343    #[test]
344    fn test_codegen_data_serde() {
345        let mut files = HashMap::new();
346        files.insert(
347            "test.rs".to_string(),
348            ProjectFileDefinition {
349                content: "fn main() {}".to_string(),
350                language: "rust".to_string(),
351                mode: FileMode::Managed,
352                format: FormatConfig::default(),
353                gitignore: false,
354            },
355        );
356        let data = CodegenData {
357            files,
358            context: serde_json::json!({"key": "value"}),
359        };
360        let json = serde_json::to_string(&data).unwrap();
361        let deserialized: CodegenData = serde_json::from_str(&json).unwrap();
362        assert!(deserialized.files.contains_key("test.rs"));
363        assert_eq!(deserialized.context["key"], "value");
364    }
365
366    #[test]
367    fn test_codegen_data_default_context() {
368        let json = r#"{"files":{}}"#;
369        let data: CodegenData = serde_json::from_str(json).unwrap();
370        assert!(data.files.is_empty());
371        assert!(data.context.is_null());
372    }
373
374    #[test]
375    fn test_codegen_accessors() {
376        let mut files = HashMap::new();
377        files.insert(
378            "example.js".to_string(),
379            ProjectFileDefinition {
380                content: "console.log('hi')".to_string(),
381                language: "javascript".to_string(),
382                mode: FileMode::Managed,
383                format: FormatConfig::default(),
384                gitignore: false,
385            },
386        );
387        let codegen = Codegen {
388            data: CodegenData {
389                files,
390                context: serde_json::json!({"project": "test"}),
391            },
392            source_path: PathBuf::from("/path/to/codegen.cue"),
393        };
394
395        assert_eq!(codegen.files().len(), 1);
396        assert!(codegen.files().contains_key("example.js"));
397        assert_eq!(codegen.context()["project"], "test");
398        assert_eq!(codegen.source_path(), Path::new("/path/to/codegen.cue"));
399    }
400
401    #[test]
402    fn test_codegen_load_nonexistent_file() {
403        let result = Codegen::load("/nonexistent/path/codegen.cue");
404        assert!(result.is_err());
405        let err = result.unwrap_err();
406        assert!(err.to_string().contains("Codegen file not found"));
407    }
408
409    #[test]
410    fn test_determine_package_name_finds_package() {
411        use std::io::Write;
412        let temp_dir = tempfile::tempdir().unwrap();
413        let file_path = temp_dir.path().join("test.cue");
414        let mut file = std::fs::File::create(&file_path).unwrap();
415        writeln!(file, "// comment").unwrap();
416        writeln!(file, "package mypackage").unwrap();
417        writeln!(file).unwrap();
418        writeln!(file, "data: 123").unwrap();
419
420        let name = Codegen::determine_package_name(&file_path).unwrap();
421        assert_eq!(name, "mypackage");
422    }
423
424    #[test]
425    fn test_determine_package_name_defaults() {
426        use std::io::Write;
427        let temp_dir = tempfile::tempdir().unwrap();
428        let file_path = temp_dir.path().join("test.cue");
429        let mut file = std::fs::File::create(&file_path).unwrap();
430        writeln!(file, "// no package declaration").unwrap();
431        writeln!(file, "data: 123").unwrap();
432
433        let name = Codegen::determine_package_name(&file_path).unwrap();
434        assert_eq!(name, "codegen");
435    }
436
437    #[test]
438    fn test_determine_package_name_file_not_found() {
439        let result = Codegen::determine_package_name(Path::new("/nonexistent/file.cue"));
440        assert!(result.is_err());
441        let err = result.unwrap_err();
442        assert!(err.to_string().contains("Failed to read codegen file"));
443    }
444
445    #[test]
446    fn test_codegen_load_no_cue_module() {
447        use std::io::Write;
448        let temp_dir = tempfile::tempdir().unwrap();
449        let file_path = temp_dir.path().join("codegen.cue");
450        let mut file = std::fs::File::create(&file_path).unwrap();
451        writeln!(file, "package test").unwrap();
452        writeln!(file, "files: {{}}").unwrap();
453
454        let result = Codegen::load(&file_path);
455        assert!(result.is_err());
456        let err = result.unwrap_err();
457        assert!(err.to_string().contains("No CUE module found"));
458    }
459}