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
47pub fn to_pascal_case(s: &str) -> String {
48  s.split(['_', '-', ' '])
49    .filter(|p| !p.is_empty())
50    .map(|word| {
51      let mut chars = word.chars();
52      match chars.next() {
53        None => String::new(),
54        Some(first) => first.to_uppercase().to_string() + chars.as_str(),
55      }
56    })
57    .collect()
58}
59
60pub fn to_camel_case(s: &str) -> String {
61  let pascal = to_pascal_case(s);
62  let mut chars = pascal.chars();
63  match chars.next() {
64    None => String::new(),
65    Some(first) => first.to_lowercase().to_string() + chars.as_str(),
66  }
67}
68
69/// Normalises `base.join(relative)` lexically (no filesystem I/O) and
70/// verifies the result stays within `base`.  Prevents path-traversal in
71/// template archives (e.g. `../../.bashrc`).
72fn safe_template_path(base: &Path, relative: &Path) -> Result<PathBuf> {
73  let joined = base.join(relative);
74  let mut out = PathBuf::new();
75  for component in joined.components() {
76    match component {
77      Component::ParentDir => {
78        out.pop();
79      }
80      Component::CurDir => {}
81      c => out.push(c),
82    }
83  }
84  // Normalise base the same way for comparison
85  let mut norm_base = PathBuf::new();
86  for component in base.components() {
87    match component {
88      Component::ParentDir => {
89        norm_base.pop();
90      }
91      Component::CurDir => {}
92      c => norm_base.push(c),
93    }
94  }
95  if !out.starts_with(&norm_base) {
96    return Err(anyhow!(
97      "Path traversal blocked: template file '{}' would escape the output directory",
98      relative.display()
99    ));
100  }
101  Ok(out)
102}
103
104pub fn extract_dir_contents(
105  files: &[TemplateFile],
106  base_path: &Path,
107  tera: &mut Tera,
108  context: &Context,
109) -> Result<()> {
110  for file in files {
111    let file_name = file
112      .path
113      .file_name()
114      .ok_or_else(|| anyhow::anyhow!("Invalid file path: {}", file.path.display()))?;
115    let file_name_str = file_name.to_string_lossy();
116    let template_key = file.path.to_string_lossy();
117
118    let output_path = safe_template_path(base_path, &file.path)?;
119    if let Some(parent) = output_path.parent() {
120      fs::create_dir_all(parent)?;
121    }
122
123    if file_name_str.ends_with(".tera") {
124      let output_name = file_name_str.trim_end_matches(".tera");
125      let output_path = output_path.with_file_name(output_name);
126
127      let template_content = std::str::from_utf8(&file.contents)?;
128      tera.add_raw_template(&template_key, template_content)?;
129      let rendered = tera.render(&template_key, context)?;
130
131      fs::write(&output_path, rendered)?;
132      println!("  ✓ {}", output_path.display());
133    } else {
134      fs::write(&output_path, &file.contents)?;
135      println!("  ✓ {}", output_path.display());
136    }
137  }
138  Ok(())
139}