ggen_core/generator.rs
1//! Template generation engine
2//!
3//! This module provides the core generation engine that orchestrates template
4//! processing, RDF graph operations, and file generation. The `Generator` type
5//! coordinates the entire generation pipeline from template parsing to output
6//! file creation.
7//!
8//! ## Architecture
9//!
10//! The generation process follows these steps:
11//! 1. Parse template (frontmatter + body)
12//! 2. Render frontmatter with variables
13//! 3. Process RDF graph (load data, execute SPARQL queries)
14//! 4. Render template body with full context
15//! 5. Handle file injection/merging if needed
16//! 6. Write output file
17//!
18//! ## Constants
19//!
20//! **Kaizen improvement**: Extracted magic numbers to named constants for clarity and maintainability.
21/// Maximum length for sanitized environment variable keys
22const MAX_ENV_KEY_LENGTH: usize = 100;
23
24/// Maximum length for sanitized environment variable values
25const MAX_ENV_VALUE_LENGTH: usize = 10000;
26
27// ## Examples
28//
29// ### Basic Generation
30//
31// ```rust,no_run
32// use ggen_core::generator::{Generator, GenContext};
33// use ggen_core::pipeline::Pipeline;
34// use std::collections::BTreeMap;
35// use std::path::PathBuf;
36//
37// # fn main() -> ggen_utils::error::Result<()> {
38// let pipeline = Pipeline::new()?;
39// let ctx = GenContext::new(
40// PathBuf::from("template.tmpl"),
41// PathBuf::from("output")
42// ).with_vars({
43// let mut vars = BTreeMap::new();
44// vars.insert("name".to_string(), "MyApp".to_string());
45// vars
46// });
47//
48// let mut generator = Generator::new(pipeline, ctx);
49// let output_path = generator.generate()?;
50// println!("Generated: {:?}", output_path);
51// # Ok(())
52// # }
53// ```
54//
55// ### Dry Run
56//
57// ```rust,no_run
58// use ggen_core::generator::{Generator, GenContext};
59// use ggen_core::pipeline::Pipeline;
60// use std::path::PathBuf;
61//
62// fn main() -> ggen_utils::error::Result<()> {
63// let pipeline = Pipeline::new()?;
64// let ctx = GenContext::new(
65// PathBuf::from("template.tmpl"),
66// PathBuf::from("output")
67// ).dry(true); // Enable dry run mode
68//
69// let mut generator = Generator::new(pipeline, ctx);
70// let output_path = generator.generate()?;
71// // File is not actually written in dry run mode
72// println!("Generated (dry run): {}", output_path);
73// Ok(())
74// }
75// ```
76
77use ggen_utils::error::Result;
78use std::collections::BTreeMap;
79use std::env;
80use std::fs;
81use std::path::PathBuf;
82use tera::Context;
83
84use crate::pipeline::Pipeline;
85use crate::template::Template;
86use crate::templates::frozen::FrozenMerger;
87
88/// Context for template generation with paths, variables, and configuration
89pub struct GenContext {
90 pub template_path: PathBuf,
91 pub output_root: PathBuf,
92 pub vars: BTreeMap<String, String>,
93 pub global_prefixes: BTreeMap<String, String>,
94 pub base: Option<String>,
95 pub dry_run: bool,
96}
97
98impl GenContext {
99 /// Create a new generation context
100 ///
101 /// # Examples
102 ///
103 /// ```rust
104 /// use ggen_core::generator::GenContext;
105 /// use std::path::PathBuf;
106 ///
107 /// let ctx = GenContext::new(
108 /// PathBuf::from("template.tmpl"),
109 /// PathBuf::from("output")
110 /// );
111 /// assert_eq!(ctx.template_path, PathBuf::from("template.tmpl"));
112 /// assert_eq!(ctx.output_root, PathBuf::from("output"));
113 /// assert!(ctx.vars.is_empty());
114 /// assert!(!ctx.dry_run);
115 /// ```
116 pub fn new(template_path: PathBuf, output_root: PathBuf) -> Self {
117 Self {
118 template_path,
119 output_root,
120 vars: BTreeMap::new(),
121 global_prefixes: BTreeMap::new(),
122 base: None,
123 dry_run: false,
124 }
125 }
126 /// Add variables to the generation context
127 ///
128 /// # Examples
129 ///
130 /// ```rust
131 /// use ggen_core::generator::GenContext;
132 /// use std::collections::BTreeMap;
133 /// use std::path::PathBuf;
134 ///
135 /// let mut vars = BTreeMap::new();
136 /// vars.insert("name".to_string(), "MyApp".to_string());
137 /// vars.insert("version".to_string(), "1.0.0".to_string());
138 ///
139 /// let ctx = GenContext::new(
140 /// PathBuf::from("template.tmpl"),
141 /// PathBuf::from("output")
142 /// ).with_vars(vars);
143 ///
144 /// assert_eq!(ctx.vars.get("name"), Some(&"MyApp".to_string()));
145 /// assert_eq!(ctx.vars.get("version"), Some(&"1.0.0".to_string()));
146 /// ```
147 pub fn with_vars(mut self, vars: BTreeMap<String, String>) -> Self {
148 self.vars = vars;
149 self
150 }
151 /// Add RDF prefixes and base IRI to the context
152 ///
153 /// # Example
154 ///
155 /// ```rust
156 /// use ggen_core::generator::GenContext;
157 /// use std::collections::BTreeMap;
158 /// use std::path::PathBuf;
159 ///
160 /// let mut prefixes = BTreeMap::new();
161 /// prefixes.insert("ex".to_string(), "http://example.org/".to_string());
162 ///
163 /// let ctx = GenContext::new(
164 /// PathBuf::from("template.tmpl"),
165 /// PathBuf::from("output")
166 /// ).with_prefixes(prefixes, Some("http://example.org/".to_string()));
167 ///
168 /// assert_eq!(ctx.global_prefixes.get("ex"), Some(&"http://example.org/".to_string()));
169 /// assert_eq!(ctx.base, Some("http://example.org/".to_string()));
170 /// ```
171 pub fn with_prefixes(
172 mut self, prefixes: BTreeMap<String, String>, base: Option<String>,
173 ) -> Self {
174 self.global_prefixes = prefixes;
175 self.base = base;
176 self
177 }
178 /// Enable or disable dry run mode
179 ///
180 /// When dry run is enabled, files are not actually written to disk.
181 ///
182 /// # Examples
183 ///
184 /// ```rust
185 /// use ggen_core::generator::GenContext;
186 /// use std::path::PathBuf;
187 ///
188 /// let ctx = GenContext::new(
189 /// PathBuf::from("template.tmpl"),
190 /// PathBuf::from("output")
191 /// ).dry(true);
192 ///
193 /// assert!(ctx.dry_run);
194 /// ```
195 pub fn dry(mut self, dry: bool) -> Self {
196 self.dry_run = dry;
197 self
198 }
199}
200
201/// Main generator that orchestrates template processing and file generation
202pub struct Generator {
203 pub pipeline: Pipeline,
204 pub ctx: GenContext,
205}
206
207impl Generator {
208 /// Create a new generator with a pipeline and context
209 ///
210 /// # Example
211 ///
212 /// ```rust,no_run
213 /// use ggen_core::generator::{Generator, GenContext};
214 /// use ggen_core::pipeline::Pipeline;
215 /// use std::path::PathBuf;
216 ///
217 /// # fn main() -> ggen_utils::error::Result<()> {
218 /// let pipeline = Pipeline::new()?;
219 /// let ctx = GenContext::new(
220 /// PathBuf::from("template.tmpl"),
221 /// PathBuf::from("output")
222 /// );
223 /// let generator = Generator::new(pipeline, ctx);
224 /// # Ok(())
225 /// # }
226 /// ```
227 pub fn new(pipeline: Pipeline, ctx: GenContext) -> Self {
228 Self { pipeline, ctx }
229 }
230
231 /// Generate output from the template
232 ///
233 /// Processes the template, renders it with the provided context,
234 /// and writes the output to the specified location.
235 ///
236 /// # Returns
237 ///
238 /// Returns the path to the generated file. The path is returned even in
239 /// dry run mode (when `dry_run` is `true`), allowing you to preview where
240 /// the file would be written without actually creating it.
241 ///
242 /// The output path is determined as follows:
243 /// - If the template frontmatter specifies a `to` field, that path is used
244 /// (rendered with template variables)
245 /// - Otherwise, the output path defaults to the template filename with a
246 /// `.out` extension in the output root directory
247 ///
248 /// # Behavior
249 ///
250 /// - **Parent directories**: Automatically creates parent directories as needed
251 /// - **Frozen sections**: If the output file already exists and contains frozen
252 /// sections (marked with `# frozen` comments), those sections are preserved
253 /// and merged with the new generated content
254 /// - **Dry run**: When `dry_run` is `true`, the file is not written to disk,
255 /// but the path is still returned
256 ///
257 /// # Errors
258 ///
259 /// Returns an error if:
260 /// - The template file cannot be read
261 /// - The template syntax is invalid
262 /// - Template variables are missing or invalid
263 /// - RDF processing fails (if RDF is used)
264 /// - The template path has no file stem (cannot determine default output name)
265 /// - The output file cannot be written (unless in dry run mode)
266 /// - File system permissions are insufficient
267 /// - Parent directories cannot be created
268 ///
269 /// # Examples
270 ///
271 /// ## Success case
272 ///
273 /// ```rust,no_run
274 /// use ggen_core::generator::{Generator, GenContext};
275 /// use ggen_core::pipeline::Pipeline;
276 /// use std::collections::BTreeMap;
277 /// use std::path::PathBuf;
278 ///
279 /// # fn main() -> ggen_utils::error::Result<()> {
280 /// let pipeline = Pipeline::new()?;
281 /// let mut vars = BTreeMap::new();
282 /// vars.insert("name".to_string(), "MyApp".to_string());
283 ///
284 /// let ctx = GenContext::new(
285 /// PathBuf::from("template.tmpl"),
286 /// PathBuf::from("output")
287 /// ).with_vars(vars);
288 ///
289 /// let mut generator = Generator::new(pipeline, ctx);
290 /// let output_path = generator.generate()?;
291 /// println!("Generated: {:?}", output_path);
292 /// # Ok(())
293 /// # }
294 /// ```
295 ///
296 /// ## Error case - Template file not found
297 ///
298 /// ```rust,no_run
299 /// use ggen_core::generator::{Generator, GenContext};
300 /// use ggen_core::pipeline::Pipeline;
301 /// use std::path::PathBuf;
302 ///
303 /// # fn main() -> ggen_utils::error::Result<()> {
304 /// let pipeline = Pipeline::new()?;
305 /// let ctx = GenContext::new(
306 /// PathBuf::from("nonexistent.tmpl"), // File doesn't exist
307 /// PathBuf::from("output")
308 /// );
309 ///
310 /// let mut generator = Generator::new(pipeline, ctx);
311 /// // This will fail because the template file doesn't exist
312 /// let result = generator.generate();
313 /// assert!(result.is_err());
314 /// # Ok(())
315 /// # }
316 /// ```
317 pub fn generate(&mut self) -> Result<PathBuf> {
318 let input = fs::read_to_string(&self.ctx.template_path)?;
319 let mut tmpl = Template::parse(&input)?;
320
321 // Context
322 // Security: Sanitize template variables to prevent code injection
323 // Tera templates can execute arbitrary code, so we need to ensure
324 // variables don't contain executable content
325 let sanitized_vars = self
326 .ctx
327 .vars
328 .iter()
329 .map(|(k, v)| {
330 // Basic sanitization: remove control characters and limit length
331 let sanitized_key = k
332 .chars()
333 .filter(|c| !c.is_control())
334 .take(MAX_ENV_KEY_LENGTH)
335 .collect::<String>();
336 let sanitized_value = v
337 .chars()
338 .filter(|c| !c.is_control())
339 .take(MAX_ENV_VALUE_LENGTH)
340 .collect::<String>();
341 (sanitized_key, sanitized_value)
342 })
343 .collect::<BTreeMap<String, String>>();
344 let mut tctx = Context::from_serialize(&sanitized_vars)?;
345 insert_env(&mut tctx);
346
347 // Render frontmatter
348 tmpl.render_frontmatter(&mut self.pipeline.tera, &tctx)?;
349
350 // Process graph
351 tmpl.process_graph(
352 &mut self.pipeline.graph,
353 &mut self.pipeline.tera,
354 &tctx,
355 &self.ctx.template_path,
356 )?;
357
358 // Render body
359 let rendered = tmpl.render(&mut self.pipeline.tera, &tctx)?;
360
361 // Determine output path
362 let output_path = if let Some(to_path) = &tmpl.front.to {
363 let rendered_to = self.pipeline.tera.render_str(to_path, &tctx)?;
364 let joined_path = self.ctx.output_root.join(&rendered_to);
365
366 // Security: Prevent path traversal attacks by ensuring the resolved path
367 // stays within output_root. This prevents paths like "../../../etc/passwd"
368 // from escaping the output directory.
369 // We normalize the path and check that all components stay within output_root
370 let normalized = joined_path.components().collect::<Vec<_>>();
371 let output_root_components = self.ctx.output_root.components().collect::<Vec<_>>();
372
373 // Check that normalized path starts with output_root components
374 if normalized.len() < output_root_components.len() {
375 return Err(ggen_utils::error::Error::new(&format!(
376 "Output path '{}' would escape output root '{}'",
377 rendered_to,
378 self.ctx.output_root.display()
379 )));
380 }
381
382 for (i, component) in output_root_components.iter().enumerate() {
383 if normalized.get(i) != Some(component) {
384 return Err(ggen_utils::error::Error::new(&format!(
385 "Output path '{}' would escape output root '{}'",
386 rendered_to,
387 self.ctx.output_root.display()
388 )));
389 }
390 }
391
392 joined_path
393 } else {
394 // Default to template name with .out extension
395 let template_name = self
396 .ctx
397 .template_path
398 .file_stem()
399 .ok_or_else(|| {
400 ggen_utils::error::Error::new(&format!(
401 "Template path has no file stem: {}",
402 self.ctx.template_path.display()
403 ))
404 })?
405 .to_string_lossy();
406 self.ctx.output_root.join(format!("{}.out", template_name))
407 };
408
409 if !self.ctx.dry_run {
410 // Ensure parent directory exists
411 if let Some(parent) = output_path.parent() {
412 fs::create_dir_all(parent)?;
413 }
414
415 // Check if file exists and has frozen sections
416 let final_content = if output_path.exists() {
417 let existing_content = fs::read_to_string(&output_path)?;
418 if FrozenMerger::has_frozen_sections(&existing_content) {
419 // Merge frozen sections from existing file
420 FrozenMerger::merge_with_frozen(&existing_content, &rendered)?
421 } else {
422 rendered
423 }
424 } else {
425 rendered
426 };
427
428 fs::write(&output_path, final_content)?;
429 }
430
431 Ok(output_path)
432 }
433}
434
435fn insert_env(ctx: &mut Context) {
436 for (k, v) in env::vars() {
437 ctx.insert(&k, &v);
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use std::collections::BTreeMap;
445 use std::fs;
446 use tempfile::TempDir;
447
448 fn create_test_pipeline() -> Pipeline {
449 Pipeline::new().unwrap()
450 }
451
452 fn create_test_template(content: &str) -> (TempDir, PathBuf) {
453 let temp_dir = TempDir::new().expect("Failed to create temp dir");
454 let template_path = temp_dir.path().join("test.tmpl");
455 fs::write(&template_path, content).expect("Failed to write test template");
456 (temp_dir, template_path)
457 }
458
459 #[test]
460 fn test_gen_context_new() {
461 let template_path = PathBuf::from("test.tmpl");
462 let output_root = PathBuf::from("output");
463
464 let ctx = GenContext::new(template_path.clone(), output_root.clone());
465
466 assert_eq!(ctx.template_path, template_path);
467 assert_eq!(ctx.output_root, output_root);
468 assert!(ctx.vars.is_empty());
469 assert!(ctx.global_prefixes.is_empty());
470 assert!(ctx.base.is_none());
471 assert!(!ctx.dry_run);
472 }
473
474 #[test]
475 fn test_gen_context_with_vars() {
476 let template_path = PathBuf::from("test.tmpl");
477 let output_root = PathBuf::from("output");
478 let mut vars = BTreeMap::new();
479 vars.insert("name".to_string(), "TestApp".to_string());
480 vars.insert("version".to_string(), "1.0.0".to_string());
481
482 let ctx = GenContext::new(template_path, output_root).with_vars(vars.clone());
483
484 assert_eq!(ctx.vars, vars);
485 }
486
487 #[test]
488 fn test_gen_context_with_prefixes() {
489 let template_path = PathBuf::from("test.tmpl");
490 let output_root = PathBuf::from("output");
491 let mut prefixes = BTreeMap::new();
492 prefixes.insert("ex".to_string(), "http://example.org/".to_string());
493 let base = Some("http://example.org/base/".to_string());
494
495 let ctx = GenContext::new(template_path, output_root)
496 .with_prefixes(prefixes.clone(), base.clone());
497
498 assert_eq!(ctx.global_prefixes, prefixes);
499 assert_eq!(ctx.base, base);
500 }
501
502 #[test]
503 fn test_gen_context_dry() {
504 let template_path = PathBuf::from("test.tmpl");
505 let output_root = PathBuf::from("output");
506
507 let ctx = GenContext::new(template_path, output_root).dry(true);
508 assert!(ctx.dry_run);
509
510 let ctx = GenContext::new(PathBuf::from("test.tmpl"), PathBuf::from("output")).dry(false);
511 assert!(!ctx.dry_run);
512 }
513
514 #[test]
515 fn test_generator_new() {
516 let pipeline = create_test_pipeline();
517 let template_path = PathBuf::from("test.tmpl");
518 let output_root = PathBuf::from("output");
519 let ctx = GenContext::new(template_path, output_root);
520
521 let generator = Generator::new(pipeline, ctx);
522
523 // Generator should be created successfully
524 assert!(generator
525 .ctx
526 .template_path
527 .to_string_lossy()
528 .contains("test.tmpl"));
529 assert!(generator
530 .ctx
531 .output_root
532 .to_string_lossy()
533 .contains("output"));
534 }
535
536 #[allow(clippy::expect_used)]
537 #[test]
538 fn test_generate_simple_template() {
539 let (_temp_dir, template_path) = create_test_template(
540 r#"---
541to: "output/{{ name | lower }}.rs"
542---
543// Generated by ggen
544// Name: {{ name }}
545// Description: {{ description }}
546"#,
547 );
548
549 let output_dir = _temp_dir.path();
550 let pipeline = create_test_pipeline();
551 let mut vars = BTreeMap::new();
552 vars.insert("name".to_string(), "MyApp".to_string());
553 vars.insert("description".to_string(), "A test application".to_string());
554
555 let ctx = GenContext::new(template_path, output_dir.to_path_buf()).with_vars(vars);
556
557 let mut generator = Generator::new(pipeline, ctx);
558 let result = generator.generate();
559
560 if let Err(e) = &result {
561 log::error!("Generation failed: {}", e);
562 }
563 assert!(result.is_ok());
564 let output_path = result.unwrap();
565 assert_eq!(output_path, output_dir.join("output/myapp.rs"));
566
567 // Verify file was created and has correct content
568 let content = fs::read_to_string(&output_path).expect("Failed to read output file");
569 assert!(content.contains("// Generated by ggen"));
570 assert!(content.contains("// Name: MyApp"));
571 assert!(content.contains("// Description: A test application"));
572 }
573
574 #[test]
575 fn test_generate_dry_run() {
576 let (_temp_dir, template_path) = create_test_template(
577 r#"---
578to: "output/{{ name | lower }}.rs"
579---
580// Generated content
581"#,
582 );
583
584 let output_dir = _temp_dir.path();
585 let pipeline = create_test_pipeline();
586 let mut vars = BTreeMap::new();
587 vars.insert("name".to_string(), "MyApp".to_string());
588
589 let ctx = GenContext::new(template_path, output_dir.to_path_buf())
590 .with_vars(vars)
591 .dry(true);
592
593 let mut generator = Generator::new(pipeline, ctx);
594 let result = generator.generate();
595
596 assert!(result.is_ok());
597 let output_path = result.unwrap();
598 assert_eq!(output_path, output_dir.join("output/myapp.rs"));
599
600 // Verify file was NOT created in dry run
601 assert!(!output_path.exists());
602 }
603
604 #[allow(clippy::expect_used)]
605 #[test]
606 fn test_generate_with_default_output() {
607 let (_temp_dir, template_path) = create_test_template(
608 r#"---
609{}
610---
611// Default output content
612"#,
613 );
614
615 let output_dir = _temp_dir.path();
616 let pipeline = create_test_pipeline();
617 let ctx = GenContext::new(template_path, output_dir.to_path_buf());
618
619 let mut generator = Generator::new(pipeline, ctx);
620 let result = generator.generate();
621
622 if let Err(e) = &result {
623 log::error!("Generation failed: {}", e);
624 }
625 assert!(result.is_ok());
626 let output_path = result.unwrap();
627 assert_eq!(output_path, output_dir.join("test.out"));
628
629 // Verify file was created
630 let content = fs::read_to_string(&output_path).expect("Failed to read output file");
631 assert!(content.contains("// Default output content"));
632 }
633
634 #[allow(clippy::expect_used)]
635 #[test]
636 fn test_generate_with_nested_output_path() {
637 let (_temp_dir, template_path) = create_test_template(
638 r#"---
639to: "src/{{ module }}/{{ name | lower }}.rs"
640---
641// Nested output content
642"#,
643 );
644
645 let output_dir = _temp_dir.path();
646 let pipeline = create_test_pipeline();
647 let mut vars = BTreeMap::new();
648 vars.insert("name".to_string(), "MyModule".to_string());
649 vars.insert("module".to_string(), "handlers".to_string());
650
651 let ctx = GenContext::new(template_path, output_dir.to_path_buf()).with_vars(vars);
652
653 let mut generator = Generator::new(pipeline, ctx);
654 let result = generator.generate();
655
656 assert!(result.is_ok());
657 let output_path = result.unwrap();
658 assert_eq!(output_path, output_dir.join("src/handlers/mymodule.rs"));
659
660 // Verify directory was created and file exists
661 assert!(output_path.exists());
662 let content = fs::read_to_string(&output_path).expect("Failed to read output file");
663 assert!(content.contains("// Nested output content"));
664 }
665
666 #[allow(clippy::expect_used)]
667 #[test]
668 fn test_generate_invalid_template() {
669 let (_temp_dir, template_path) = create_test_template(
670 r#"---
671invalid_yaml: [unclosed
672---
673// Template body
674"#,
675 );
676
677 let output_dir = _temp_dir.path();
678 let pipeline = create_test_pipeline();
679 let ctx = GenContext::new(template_path, output_dir.to_path_buf());
680
681 let mut generator = Generator::new(pipeline, ctx);
682 let result = generator.generate();
683
684 // Should fail due to invalid YAML in frontmatter
685 assert!(result.is_err());
686 }
687
688 #[allow(clippy::expect_used)]
689 #[test]
690 fn test_generate_missing_template_file() {
691 let temp_dir = TempDir::new().expect("Failed to create temp dir");
692 let template_path = temp_dir.path().join("nonexistent.tmpl");
693 let output_dir = temp_dir.path();
694
695 let pipeline = create_test_pipeline();
696 let ctx = GenContext::new(template_path, output_dir.to_path_buf());
697
698 let mut generator = Generator::new(pipeline, ctx);
699 let result = generator.generate();
700
701 // Should fail due to missing template file
702 assert!(result.is_err());
703 }
704
705 #[test]
706 fn test_insert_env() {
707 let mut ctx = Context::new();
708
709 // Set a test environment variable
710 std::env::set_var("TEST_GGEN_VAR", "test_value");
711
712 insert_env(&mut ctx);
713
714 // Verify environment variable was inserted
715 assert!(ctx.get("TEST_GGEN_VAR").is_some());
716
717 // Clean up
718 std::env::remove_var("TEST_GGEN_VAR");
719 }
720}