Skip to main content

torvyn_cli/templates/
mod.rs

1//! Template registry and expansion for `torvyn init`.
2//!
3//! Templates are embedded in the binary. Each template provides a complete
4//! set of files needed for a specific component pattern.
5
6pub mod content;
7
8use crate::cli::TemplateKind;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12/// A single file in a template, with its relative path and content.
13#[derive(Debug, Clone)]
14pub struct TemplateFile {
15    /// Path relative to the project root.
16    pub relative_path: PathBuf,
17    /// File content with substitution tokens.
18    pub content: String,
19}
20
21/// The complete set of files for a template.
22#[derive(Debug, Clone)]
23pub struct Template {
24    /// Human-readable description.
25    #[allow(dead_code)]
26    pub description: String,
27    /// Files to generate.
28    pub files: Vec<TemplateFile>,
29}
30
31/// Substitution variables available to templates.
32#[derive(Debug, Clone)]
33pub struct TemplateVars {
34    /// Project name (kebab-case).
35    pub project_name: String,
36    /// Component type (PascalCase).
37    pub component_type: String,
38    /// Date string.
39    pub date: String,
40    /// Torvyn CLI version.
41    pub torvyn_version: String,
42    /// Contract version.
43    pub contract_version: String,
44}
45
46impl TemplateVars {
47    /// Create template variables from the init arguments.
48    ///
49    /// COLD PATH — called once during init.
50    pub fn new(project_name: &str, contract_version: &str) -> Self {
51        Self {
52            project_name: project_name.to_string(),
53            component_type: to_pascal_case(project_name),
54            date: chrono::Utc::now().format("%Y-%m-%d").to_string(),
55            torvyn_version: env!("CARGO_PKG_VERSION").to_string(),
56            contract_version: contract_version.to_string(),
57        }
58    }
59
60    /// Build the substitution map.
61    fn to_map(&self) -> HashMap<&'static str, &str> {
62        let mut m = HashMap::new();
63        m.insert("project_name", self.project_name.as_str());
64        m.insert("component_type", self.component_type.as_str());
65        m.insert("date", self.date.as_str());
66        m.insert("torvyn_version", self.torvyn_version.as_str());
67        m.insert("contract_version", self.contract_version.as_str());
68        m
69    }
70}
71
72/// Convert a kebab-case string to PascalCase.
73///
74/// # Examples
75/// ```
76/// # use torvyn_cli::templates::to_pascal_case;
77/// assert_eq!(to_pascal_case("my-transform"), "MyTransform");
78/// assert_eq!(to_pascal_case("hello"), "Hello");
79/// assert_eq!(to_pascal_case("a-b-c"), "ABC");
80/// ```
81pub fn to_pascal_case(s: &str) -> String {
82    s.split('-')
83        .filter(|p| !p.is_empty())
84        .map(|part| {
85            let mut chars = part.chars();
86            match chars.next() {
87                None => String::new(),
88                Some(c) => {
89                    let upper: String = c.to_uppercase().collect();
90                    upper + &chars.as_str().to_lowercase()
91                }
92            }
93        })
94        .collect()
95}
96
97/// Apply variable substitution to a template string.
98///
99/// Replaces all `{{key}}` patterns with the corresponding value from `vars`.
100pub fn substitute(template: &str, vars: &TemplateVars) -> String {
101    let map = vars.to_map();
102    let mut result = template.to_string();
103    for (key, value) in &map {
104        let token = format!("{{{{{key}}}}}");
105        result = result.replace(&token, value);
106    }
107    result
108}
109
110/// Get the template for the given kind.
111pub fn get_template(kind: TemplateKind) -> Template {
112    match kind {
113        TemplateKind::Transform => content::transform_template(),
114        TemplateKind::Source => content::source_template(),
115        TemplateKind::Sink => content::sink_template(),
116        TemplateKind::Filter => content::filter_template(),
117        TemplateKind::Router => content::router_template(),
118        TemplateKind::Aggregator => content::aggregator_template(),
119        TemplateKind::FullPipeline => content::full_pipeline_template(),
120        TemplateKind::Empty => content::empty_template(),
121    }
122}
123
124/// Expand a template into real files at the specified directory.
125///
126/// # Errors
127/// - Returns `std::io::Error` if any file write fails.
128pub fn expand_template(
129    template: &Template,
130    vars: &TemplateVars,
131    target_dir: &Path,
132) -> Result<Vec<PathBuf>, std::io::Error> {
133    let mut created_files = Vec::new();
134    for tf in &template.files {
135        let content = substitute(&tf.content, vars);
136        let full_path = target_dir.join(&tf.relative_path);
137
138        if let Some(parent) = full_path.parent() {
139            std::fs::create_dir_all(parent)?;
140        }
141        std::fs::write(&full_path, &content)?;
142        created_files.push(tf.relative_path.clone());
143    }
144    Ok(created_files)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_to_pascal_case_basic() {
153        assert_eq!(to_pascal_case("my-transform"), "MyTransform");
154    }
155
156    #[test]
157    fn test_to_pascal_case_single_word() {
158        assert_eq!(to_pascal_case("hello"), "Hello");
159    }
160
161    #[test]
162    fn test_to_pascal_case_multi_segment() {
163        assert_eq!(to_pascal_case("a-b-c"), "ABC");
164    }
165
166    #[test]
167    fn test_to_pascal_case_already_capitalized() {
168        assert_eq!(to_pascal_case("My-Thing"), "MyThing");
169    }
170
171    #[test]
172    fn test_substitute_basic() {
173        let vars = TemplateVars::new("my-project", "0.1.0");
174        let result = substitute("name = \"{{project_name}}\"", &vars);
175        assert_eq!(result, "name = \"my-project\"");
176    }
177
178    #[test]
179    fn test_substitute_multiple_vars() {
180        let vars = TemplateVars::new("my-project", "0.1.0");
181        let result = substitute("struct {{component_type}}; // v{{contract_version}}", &vars);
182        assert_eq!(result, "struct MyProject; // v0.1.0");
183    }
184
185    #[test]
186    fn test_substitute_unknown_token_preserved() {
187        let vars = TemplateVars::new("x", "0.1.0");
188        let result = substitute("{{unknown_token}}", &vars);
189        assert_eq!(result, "{{unknown_token}}");
190    }
191
192    #[test]
193    fn test_get_template_returns_nonempty() {
194        for kind in [
195            TemplateKind::Source,
196            TemplateKind::Sink,
197            TemplateKind::Transform,
198            TemplateKind::Filter,
199            TemplateKind::Empty,
200            TemplateKind::FullPipeline,
201        ] {
202            let t = get_template(kind);
203            assert!(!t.files.is_empty(), "Template {kind:?} has no files");
204        }
205    }
206}