oxide_cli/templates/
generator.rs1use 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
69fn 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 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}