cuenv_cubes/
cube.rs

1//! CUE Cube loading and evaluation
2//!
3//! This module handles loading CUE Cubes and evaluating them to extract
4//! file definitions. A "Cube" 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 serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14/// File generation mode
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum FileMode {
18    /// Always regenerate this file (managed by codegen)
19    #[default]
20    Managed,
21    /// Generate only if file doesn't exist (user owns this file)
22    Scaffold,
23}
24
25/// Format configuration for a code file
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct FormatConfig {
28    /// Indent style: "space" or "tab"
29    pub indent: String,
30    /// Indent size (number of spaces or tab width)
31    #[serde(rename = "indentSize")]
32    pub indent_size: Option<usize>,
33    /// Maximum line width
34    #[serde(rename = "lineWidth")]
35    pub line_width: Option<usize>,
36    /// Trailing comma style
37    #[serde(rename = "trailingComma")]
38    pub trailing_comma: Option<String>,
39    /// Use semicolons
40    pub semicolons: Option<bool>,
41    /// Quote style: "single" or "double"
42    pub quotes: Option<String>,
43}
44
45impl Default for FormatConfig {
46    fn default() -> Self {
47        Self {
48            indent: "space".to_string(),
49            indent_size: Some(2),
50            line_width: Some(100),
51            trailing_comma: None,
52            semicolons: None,
53            quotes: None,
54        }
55    }
56}
57
58/// A project file definition from the cube
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ProjectFileDefinition {
61    /// Content of the file
62    pub content: String,
63    /// Programming language of the file
64    pub language: String,
65    /// Generation mode (managed or scaffold)
66    #[serde(default)]
67    pub mode: FileMode,
68    /// Formatting configuration
69    #[serde(default)]
70    pub format: FormatConfig,
71    /// Whether to add this file path to .gitignore
72    #[serde(default)]
73    pub gitignore: bool,
74}
75
76/// A CUE Cube containing file definitions
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct CubeData {
79    /// Map of file paths to their definitions
80    pub files: HashMap<String, ProjectFileDefinition>,
81    /// Optional context data
82    #[serde(default)]
83    pub context: serde_json::Value,
84}
85
86/// CUE Cube loader and evaluator
87///
88/// A Cube is a CUE-based template that generates multiple project files.
89/// Think of it as a 3D blueprint - each face of the cube represents
90/// different aspects of your project (source code, config, tests, etc.)
91#[derive(Debug)]
92pub struct Cube {
93    /// The cube data containing file definitions
94    pub data: CubeData,
95    /// Path to the source CUE file
96    pub source_path: PathBuf,
97}
98
99impl Cube {
100    /// Load a cube 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 cube
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 cube
131    #[must_use]
132    pub fn source_path(&self) -> &Path {
133        &self.source_path
134    }
135
136    /// Evaluate a CUE file and extract the cube data
137    fn evaluate_cue(path: &Path) -> Result<CubeData> {
138        // Verify the file exists
139        if !path.exists() {
140            return Err(CodegenError::Cube(format!(
141                "Cube file not found: {}",
142                path.display()
143            )));
144        }
145
146        // Determine the directory and package name from the path
147        let dir_path = path.parent().ok_or_else(|| {
148            CodegenError::Cube("Invalid cube path: no parent directory".to_string())
149        })?;
150
151        // Determine package name - try to infer from file content or use default
152        let package_name = Self::determine_package_name(path)?;
153
154        // Find the module root
155        let module_root = Self::find_cue_module_root(dir_path).ok_or_else(|| {
156            CodegenError::Cube(format!(
157                "No CUE module found (looking for cue.mod/) starting from: {}",
158                dir_path.display()
159            ))
160        })?;
161
162        // Use module-wide evaluation
163        let options = ModuleEvalOptions {
164            recursive: true,
165            ..Default::default()
166        };
167        let raw_result = cuengine::evaluate_module(&module_root, &package_name, Some(options))
168            .map_err(|e| CodegenError::Cube(format!("CUE evaluation failed: {e}")))?;
169
170        let module = ModuleEvaluation::from_raw(
171            module_root.clone(),
172            raw_result.instances,
173            raw_result.projects,
174        );
175
176        // Calculate relative path and get the instance
177        let target_path = dir_path
178            .canonicalize()
179            .map_err(|e| CodegenError::Cube(format!("Failed to canonicalize path: {e}")))?;
180        let relative_path = target_path.strip_prefix(&module_root).map_or_else(
181            |_| PathBuf::from("."),
182            |p| {
183                if p.as_os_str().is_empty() {
184                    PathBuf::from(".")
185                } else {
186                    p.to_path_buf()
187                }
188            },
189        );
190
191        let instance = module.get(&relative_path).ok_or_else(|| {
192            CodegenError::Cube(format!(
193                "No CUE instance found at path: {} (relative: {})",
194                dir_path.display(),
195                relative_path.display()
196            ))
197        })?;
198
199        instance
200            .deserialize()
201            .map_err(|e| CodegenError::Cube(format!("Failed to deserialize cube data: {e}")))
202    }
203
204    /// Find the CUE module root by walking up from `start` looking for `cue.mod/` directory.
205    fn find_cue_module_root(start: &Path) -> Option<PathBuf> {
206        let mut current = start.canonicalize().ok()?;
207        loop {
208            if current.join("cue.mod").is_dir() {
209                return Some(current);
210            }
211            if !current.pop() {
212                return None;
213            }
214        }
215    }
216
217    /// Determine the CUE package name from a file
218    ///
219    /// Reads the first few lines of the file to find a `package` declaration.
220    /// Falls back to "cubes" if not found.
221    fn determine_package_name(path: &Path) -> Result<String> {
222        let content = std::fs::read_to_string(path)
223            .map_err(|e| CodegenError::Cube(format!("Failed to read cube file: {e}")))?;
224
225        // Look for package declaration in the first few lines
226        for line in content.lines().take(10) {
227            let trimmed = line.trim();
228            if trimmed.starts_with("package ") {
229                // Extract package name
230                let package_name = trimmed.strip_prefix("package ").unwrap_or("cubes").trim();
231                return Ok(package_name.to_string());
232            }
233        }
234
235        // Default to "cubes" if no package declaration found
236        Ok("cubes".to_string())
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_file_mode_default() {
246        assert_eq!(FileMode::default(), FileMode::Managed);
247    }
248
249    #[test]
250    fn test_format_config_default() {
251        let config = FormatConfig::default();
252        assert_eq!(config.indent, "space");
253        assert_eq!(config.indent_size, Some(2));
254        assert_eq!(config.line_width, Some(100));
255    }
256}