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}