actr_cli/templates/
mod.rs

1//! Project template system
2
3pub mod kotlin;
4pub mod python;
5pub mod rust;
6pub mod swift;
7
8use self::kotlin::KotlinTemplate;
9use self::python::PythonTemplate;
10use self::rust::RustTemplate;
11use self::swift::SwiftTemplate;
12use crate::error::Result;
13use crate::utils::{to_pascal_case, to_snake_case};
14use clap::ValueEnum;
15use handlebars::Handlebars;
16use serde::Serialize;
17use std::collections::HashMap;
18use std::path::Path;
19
20pub use crate::commands::SupportedLanguage;
21
22pub const DEFAULT_ACTR_SWIFT_VERSION: &str = "0.1.15";
23pub const DEFAULT_ACTR_PROTOCOLS_VERSION: &str = "0.1.2";
24
25/// Project template options
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize)]
27#[value(rename_all = "lowercase")]
28pub enum ProjectTemplateName {
29    #[default]
30    Echo,
31    #[value(name = "data-stream")]
32    DataStream,
33}
34
35impl ProjectTemplateName {
36    /// Maps template name to remote service name
37    pub fn to_service_name(self) -> &'static str {
38        match self {
39            ProjectTemplateName::Echo => "echo-service",
40            ProjectTemplateName::DataStream => "data-stream-service",
41        }
42    }
43}
44
45impl std::fmt::Display for ProjectTemplateName {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        let pv = self
48            .to_possible_value()
49            .expect("ValueEnum variant must have a possible value");
50        write!(f, "{}", pv.get_name())
51    }
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct TemplateContext {
56    #[serde(rename = "PROJECT_NAME")]
57    pub project_name: String,
58    #[serde(rename = "PROJECT_NAME_SNAKE")]
59    pub project_name_snake: String,
60    #[serde(rename = "PROJECT_NAME_PASCAL")]
61    pub project_name_pascal: String,
62    #[serde(rename = "SIGNALING_URL")]
63    pub signaling_url: String,
64    #[serde(rename = "MANUFACTURER")]
65    pub manufacturer: String,
66    #[serde(rename = "SERVICE_NAME")]
67    pub service_name: String,
68    #[serde(rename = "WORKLOAD_NAME")]
69    pub workload_name: String,
70    #[serde(rename = "ACTR_SWIFT_VERSION")]
71    pub actr_swift_version: String,
72    #[serde(rename = "ACTR_PROTOCOLS_VERSION")]
73    pub actr_protocols_version: String,
74    #[serde(rename = "ACTR_LOCAL_PATH")]
75    pub actr_local_path: Option<String>,
76}
77
78impl TemplateContext {
79    pub fn new(project_name: &str, signaling_url: &str, service_name: &str) -> Self {
80        let project_name_pascal = to_pascal_case(project_name);
81        Self {
82            project_name: project_name.to_string(),
83            project_name_snake: to_snake_case(project_name),
84            project_name_pascal: project_name_pascal.clone(),
85            signaling_url: signaling_url.to_string(),
86            manufacturer: "unknown".to_string(),
87            service_name: service_name.to_string(),
88            workload_name: format!("{}Workload", project_name_pascal),
89            actr_swift_version: DEFAULT_ACTR_SWIFT_VERSION.to_string(),
90            actr_protocols_version: DEFAULT_ACTR_PROTOCOLS_VERSION.to_string(),
91            actr_local_path: std::env::var("ACTR_SWIFT_LOCAL_PATH").ok(),
92        }
93    }
94
95    pub async fn new_with_versions(
96        project_name: &str,
97        signaling_url: &str,
98        service_name: &str,
99    ) -> Self {
100        let mut ctx = Self::new(project_name, signaling_url, service_name);
101
102        // Fetch latest versions in parallel with 5s timeout
103        let swift_task = crate::utils::fetch_latest_git_tag(
104            "https://github.com/actor-rtc/actr-swift",
105            &ctx.actr_swift_version,
106        );
107        let protocols_task = crate::utils::fetch_latest_git_tag(
108            "https://github.com/actor-rtc/actr-protocols-swift",
109            &ctx.actr_protocols_version,
110        );
111
112        let (swift_v, protocols_v) = tokio::join!(swift_task, protocols_task);
113
114        ctx.actr_swift_version = swift_v;
115        ctx.actr_protocols_version = protocols_v;
116
117        ctx
118    }
119}
120
121pub trait LangTemplate: Send + Sync {
122    fn load_files(&self, template_name: ProjectTemplateName) -> Result<HashMap<String, String>>;
123}
124
125pub struct ProjectTemplate {
126    name: ProjectTemplateName,
127    lang_template: Box<dyn LangTemplate>,
128}
129
130impl ProjectTemplate {
131    pub fn new(template_name: ProjectTemplateName, language: SupportedLanguage) -> Self {
132        let lang_template: Box<dyn LangTemplate> = match language {
133            SupportedLanguage::Swift => Box::new(SwiftTemplate),
134            SupportedLanguage::Kotlin => Box::new(KotlinTemplate),
135            SupportedLanguage::Python => Box::new(PythonTemplate),
136            SupportedLanguage::Rust => Box::new(RustTemplate),
137        };
138
139        Self {
140            name: template_name,
141            lang_template,
142        }
143    }
144
145    pub fn load_file(
146        fixture_path: &Path,
147        files: &mut HashMap<String, String>,
148        key: &str,
149    ) -> Result<()> {
150        let content = std::fs::read_to_string(fixture_path)?;
151        files.insert(key.to_string(), content);
152        Ok(())
153    }
154
155    pub fn generate(&self, project_path: &Path, context: &TemplateContext) -> Result<()> {
156        let files = self.lang_template.load_files(self.name)?;
157        let handlebars = Handlebars::new();
158
159        for (file_path, content) in &files {
160            let rendered_path = handlebars.render_template(file_path, context)?;
161            let rendered_content = handlebars.render_template(content, context)?;
162
163            let full_path = project_path.join(&rendered_path);
164
165            // Create parent directories if they don't exist
166            if let Some(parent) = full_path.parent() {
167                std::fs::create_dir_all(parent)?;
168            }
169
170            std::fs::write(full_path, rendered_content)?;
171        }
172
173        Ok(())
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use tempfile::TempDir;
181
182    #[test]
183    fn test_template_context() {
184        let ctx = TemplateContext::new("my-chat-service", "ws://localhost:8080", "echo-service");
185        assert_eq!(ctx.project_name, "my-chat-service");
186        assert_eq!(ctx.project_name_snake, "my_chat_service");
187        assert_eq!(ctx.project_name_pascal, "MyChatService");
188        assert_eq!(ctx.workload_name, "MyChatServiceWorkload");
189        assert_eq!(ctx.signaling_url, "ws://localhost:8080");
190        assert_eq!(ctx.actr_swift_version, DEFAULT_ACTR_SWIFT_VERSION);
191        assert_eq!(ctx.actr_protocols_version, DEFAULT_ACTR_PROTOCOLS_VERSION);
192    }
193
194    #[test]
195    fn test_project_template_new() {
196        let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
197        assert_eq!(template.name, ProjectTemplateName::Echo);
198    }
199
200    #[test]
201    fn test_project_template_generation() {
202        let temp_dir = TempDir::new().unwrap();
203        let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
204        let context = TemplateContext::new("test-app", "ws://localhost:8080", "echo-service");
205
206        template
207            .generate(temp_dir.path(), &context)
208            .expect("Failed to generate");
209
210        // Verify project.yml exists
211        assert!(temp_dir.path().join("project.yml").exists());
212        // Verify Actr.toml exists
213        assert!(temp_dir.path().join("Actr.toml").exists());
214        // Verify .gitignore exists
215        assert!(temp_dir.path().join(".gitignore").exists());
216        // Note: proto files are no longer created during init, they will be pulled via actr install
217        // Verify app directory exists
218        assert!(
219            temp_dir
220                .path()
221                .join("TestApp")
222                .join("TestApp.swift")
223                .exists()
224        );
225    }
226
227    #[test]
228    fn test_project_template_load_files() {
229        let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
230        let result = template.lang_template.load_files(ProjectTemplateName::Echo);
231        assert!(result.is_ok());
232    }
233}