1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum FileMode {
19 #[default]
21 Managed,
22 Scaffold,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct FormatConfig {
29 pub indent: String,
31 #[serde(rename = "indentSize")]
33 pub indent_size: Option<usize>,
34 #[serde(rename = "lineWidth")]
36 pub line_width: Option<usize>,
37 #[serde(rename = "trailingComma")]
39 pub trailing_comma: Option<String>,
40 pub semicolons: Option<bool>,
42 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#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ProjectFileDefinition {
62 pub content: String,
64 pub language: String,
66 #[serde(default)]
68 pub mode: FileMode,
69 #[serde(default)]
71 pub format: FormatConfig,
72 #[serde(default)]
74 pub gitignore: bool,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct CodegenData {
80 pub files: HashMap<String, ProjectFileDefinition>,
82 #[serde(default)]
84 pub context: serde_json::Value,
85}
86
87#[derive(Debug)]
92pub struct Codegen {
93 pub data: CodegenData,
95 pub source_path: PathBuf,
97}
98
99impl Codegen {
100 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
106 let path = path.as_ref();
107
108 let data = Self::evaluate_cue(path)?;
111
112 Ok(Self {
113 data,
114 source_path: path.to_path_buf(),
115 })
116 }
117
118 #[must_use]
120 pub fn files(&self) -> &HashMap<String, ProjectFileDefinition> {
121 &self.data.files
122 }
123
124 #[must_use]
126 pub fn context(&self) -> &serde_json::Value {
127 &self.data.context
128 }
129
130 #[must_use]
132 pub fn source_path(&self) -> &Path {
133 &self.source_path
134 }
135
136 #[allow(clippy::too_many_lines)]
138 fn evaluate_cue(path: &Path) -> Result<CodegenData> {
139 if !path.exists() {
141 return Err(CodegenError::Codegen(format!(
142 "Codegen file not found: {}",
143 path.display()
144 )));
145 }
146
147 let dir_path = path.parent().ok_or_else(|| {
149 CodegenError::Codegen("Invalid codegen path: no parent directory".to_string())
150 })?;
151
152 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 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 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, );
182
183 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 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 for line in content.lines().take(10) {
218 let trimmed = line.trim();
219 if trimmed.starts_with("package ") {
220 let package_name = trimmed.strip_prefix("package ").unwrap_or("codegen").trim();
222 return Ok(package_name.to_string());
223 }
224 }
225
226 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 let json = r#"{"content":"x","language":"rust"}"#;
338 let def: ProjectFileDefinition = serde_json::from_str(json).unwrap();
339 assert_eq!(def.mode, FileMode::Managed); assert!(!def.gitignore); }
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}