Skip to main content

oxide_cli/templates/
generator.rs

1use std::{
2  fs,
3  path::{Component, Path, PathBuf},
4};
5
6use anyhow::{Result, anyhow};
7use tera::{Context, Tera};
8
9use crate::templates::TemplateFile;
10
11pub fn extract_template(files: &[TemplateFile], project_name: &str) -> Result<()> {
12  let output_path = PathBuf::from(project_name);
13  fs::create_dir_all(&output_path)?;
14
15  let mut context = Context::new();
16  context.insert("project_name", project_name);
17  context.insert("project_name_kebab", &to_kebab_case(project_name));
18  context.insert("project_name_snake", &to_snake_case(project_name));
19
20  let mut tera = Tera::default();
21
22  extract_dir_contents(files, &output_path, &mut tera, &context)?;
23
24  Ok(())
25}
26
27pub fn to_kebab_case(s: &str) -> String {
28  s.chars()
29    .map(|c| match c {
30      '_' | ' ' => '-',
31      _ => c,
32    })
33    .collect::<String>()
34    .to_lowercase()
35}
36
37pub fn to_snake_case(s: &str) -> String {
38  s.chars()
39    .map(|c| match c {
40      '-' | ' ' => '_',
41      _ => c,
42    })
43    .collect::<String>()
44    .to_lowercase()
45}
46
47/// Normalises `base.join(relative)` lexically (no filesystem I/O) and
48/// verifies the result stays within `base`.  Prevents path-traversal in
49/// template archives (e.g. `../../.bashrc`).
50fn safe_template_path(base: &Path, relative: &Path) -> Result<PathBuf> {
51  let joined = base.join(relative);
52  let mut out = PathBuf::new();
53  for component in joined.components() {
54    match component {
55      Component::ParentDir => {
56        out.pop();
57      }
58      Component::CurDir => {}
59      c => out.push(c),
60    }
61  }
62  // Normalise base the same way for comparison
63  let mut norm_base = PathBuf::new();
64  for component in base.components() {
65    match component {
66      Component::ParentDir => {
67        norm_base.pop();
68      }
69      Component::CurDir => {}
70      c => norm_base.push(c),
71    }
72  }
73  if !out.starts_with(&norm_base) {
74    return Err(anyhow!(
75      "Path traversal blocked: template file '{}' would escape the output directory",
76      relative.display()
77    ));
78  }
79  Ok(out)
80}
81
82pub fn extract_dir_contents(
83  files: &[TemplateFile],
84  base_path: &Path,
85  tera: &mut Tera,
86  context: &Context,
87) -> Result<()> {
88  for file in files {
89    let file_name = file
90      .path
91      .file_name()
92      .ok_or_else(|| anyhow::anyhow!("Invalid file path: {}", file.path.display()))?;
93    let file_name_str = file_name.to_string_lossy();
94    let template_key = file.path.to_string_lossy();
95
96    let output_path = safe_template_path(base_path, &file.path)?;
97    if let Some(parent) = output_path.parent() {
98      fs::create_dir_all(parent)?;
99    }
100
101    if file_name_str.ends_with(".tera") {
102      let output_name = file_name_str.trim_end_matches(".tera");
103      let output_path = output_path.with_file_name(output_name);
104
105      let template_content = std::str::from_utf8(&file.contents)?;
106      tera.add_raw_template(&template_key, template_content)?;
107      let rendered = tera.render(&template_key, context)?;
108
109      fs::write(&output_path, rendered)?;
110      println!("  ✓ {}", output_path.display());
111    } else {
112      fs::write(&output_path, &file.contents)?;
113      println!("  ✓ {}", output_path.display());
114    }
115  }
116  Ok(())
117}