actr_cli/templates/
mod.rs

1//! Project template system
2
3pub mod kotlin;
4pub mod python;
5pub mod rust;
6pub mod swift;
7
8pub use crate::commands::SupportedLanguage;
9use crate::error::Result;
10use crate::utils::{to_pascal_case, to_snake_case};
11use clap::ValueEnum;
12use handlebars::Handlebars;
13use serde::Serialize;
14use std::collections::HashMap;
15use std::path::Path;
16
17use self::kotlin::KotlinTemplate;
18use self::python::PythonTemplate;
19use self::rust::RustTemplate;
20use self::swift::SwiftTemplate;
21
22/// Project template options
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize)]
24#[value(rename_all = "lowercase")]
25pub enum ProjectTemplateName {
26    #[default]
27    Echo,
28}
29
30impl std::fmt::Display for ProjectTemplateName {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        let pv = self
33            .to_possible_value()
34            .expect("ValueEnum variant must have a possible value");
35        write!(f, "{}", pv.get_name())
36    }
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct TemplateContext {
41    #[serde(rename = "PROJECT_NAME")]
42    pub project_name: String,
43    #[serde(rename = "PROJECT_NAME_SNAKE")]
44    pub project_name_snake: String,
45    #[serde(rename = "PROJECT_NAME_PASCAL")]
46    pub project_name_pascal: String,
47    #[serde(rename = "SIGNALING_URL")]
48    pub signaling_url: String,
49    #[serde(rename = "MANUFACTURER")]
50    pub manufacturer: String,
51    #[serde(rename = "SERVICE_NAME")]
52    pub service_name: String,
53}
54
55impl TemplateContext {
56    pub fn new(project_name: &str, signaling_url: &str) -> Self {
57        Self {
58            project_name: project_name.to_string(),
59            project_name_snake: to_snake_case(project_name),
60            project_name_pascal: to_pascal_case(project_name),
61            signaling_url: signaling_url.to_string(),
62            manufacturer: "unknown".to_string(),
63            service_name: "UnknownService".to_string(),
64        }
65    }
66}
67
68pub trait LangTemplate: Send + Sync {
69    fn load_files(&self, template_name: ProjectTemplateName) -> Result<HashMap<String, String>>;
70}
71
72pub struct ProjectTemplate {
73    name: ProjectTemplateName,
74    lang_template: Box<dyn LangTemplate>,
75}
76
77impl ProjectTemplate {
78    pub fn new(template_name: ProjectTemplateName, language: SupportedLanguage) -> Self {
79        let lang_template: Box<dyn LangTemplate> = match language {
80            SupportedLanguage::Swift => Box::new(SwiftTemplate),
81            SupportedLanguage::Kotlin => Box::new(KotlinTemplate),
82            SupportedLanguage::Python => Box::new(PythonTemplate),
83            SupportedLanguage::Rust => Box::new(RustTemplate),
84        };
85
86        Self {
87            name: template_name,
88            lang_template,
89        }
90    }
91
92    pub fn load_file(
93        fixture_path: &Path,
94        files: &mut HashMap<String, String>,
95        key: &str,
96    ) -> Result<()> {
97        let content = std::fs::read_to_string(fixture_path)?;
98        files.insert(key.to_string(), content);
99        Ok(())
100    }
101
102    pub fn generate(&self, project_path: &Path, context: &TemplateContext) -> Result<()> {
103        let files = self.lang_template.load_files(self.name)?;
104        let handlebars = Handlebars::new();
105
106        for (file_path, content) in &files {
107            let rendered_path = handlebars.render_template(file_path, context)?;
108            let rendered_content = handlebars.render_template(content, context)?;
109
110            let full_path = project_path.join(&rendered_path);
111
112            // Create parent directories if they don't exist
113            if let Some(parent) = full_path.parent() {
114                std::fs::create_dir_all(parent)?;
115            }
116
117            std::fs::write(full_path, rendered_content)?;
118        }
119
120        Ok(())
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use tempfile::TempDir;
128
129    #[test]
130    fn test_template_context() {
131        let ctx = TemplateContext::new("my-chat-service", "ws://localhost:8080");
132        assert_eq!(ctx.project_name, "my-chat-service");
133        assert_eq!(ctx.project_name_snake, "my_chat_service");
134        assert_eq!(ctx.project_name_pascal, "MyChatService");
135        assert_eq!(ctx.signaling_url, "ws://localhost:8080");
136    }
137
138    #[test]
139    fn test_project_template_new() {
140        let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
141        assert_eq!(template.name, ProjectTemplateName::Echo);
142    }
143
144    #[test]
145    fn test_project_template_generation() {
146        let temp_dir = TempDir::new().unwrap();
147        let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
148        let context = TemplateContext::new("test-app", "ws://localhost:8080");
149
150        template
151            .generate(temp_dir.path(), &context)
152            .expect("Failed to generate");
153
154        // Verify project.yml exists
155        assert!(temp_dir.path().join("project.yml").exists());
156        // Verify Actr.toml exists
157        assert!(temp_dir.path().join("Actr.toml").exists());
158        // Verify .gitignore exists
159        assert!(temp_dir.path().join(".gitignore").exists());
160        // Verify proto file exists
161        assert!(temp_dir.path().join("protos/echo.proto").exists());
162        // Verify app directory exists
163        assert!(
164            temp_dir
165                .path()
166                .join("TestApp")
167                .join("TestAppApp.swift")
168                .exists()
169        );
170    }
171
172    #[test]
173    fn test_project_template_load_files() {
174        let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
175        let result = template.lang_template.load_files(ProjectTemplateName::Echo);
176        assert!(result.is_ok());
177    }
178}