Skip to main content

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