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;
7pub mod typescript;
8
9use self::kotlin::KotlinTemplate;
10use self::python::PythonTemplate;
11use self::rust::RustTemplate;
12use self::swift::SwiftTemplate;
13use self::typescript::TypeScriptTemplate;
14use crate::assets::FixtureAssets;
15use crate::error::{ActrCliError, Result};
16use crate::utils::{to_pascal_case, to_snake_case};
17use clap::ValueEnum;
18use handlebars::Handlebars;
19use serde::Serialize;
20use std::collections::HashMap;
21use std::io::ErrorKind;
22use std::path::{Path, PathBuf};
23
24pub use crate::commands::SupportedLanguage;
25
26pub const DEFAULT_ACTR_SWIFT_VERSION: &str = "0.1.15";
27pub const DEFAULT_ACTR_PROTOCOLS_VERSION: &str = "0.1.2";
28pub const DEFAULT_MANUFACTURER: &str = "acme";
29
30/// Project template options
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize)]
32#[value(rename_all = "lowercase")]
33pub enum ProjectTemplateName {
34    #[default]
35    Echo,
36    Empty,
37    #[value(name = "data-stream")]
38    DataStream,
39}
40
41impl ProjectTemplateName {
42    /// Maps template name to remote service name
43    pub fn to_service_name(self) -> &'static str {
44        match self {
45            ProjectTemplateName::Echo => "echo-service",
46            ProjectTemplateName::Empty => "empty-service",
47            ProjectTemplateName::DataStream => "data-stream-service",
48        }
49    }
50}
51
52impl std::fmt::Display for ProjectTemplateName {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        let pv = self
55            .to_possible_value()
56            .expect("ValueEnum variant must have a possible value");
57        write!(f, "{}", pv.get_name())
58    }
59}
60
61/// Role for the echo template: service (provides EchoService), app (calls EchoService),
62/// or both (generate app and service projects).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize)]
64#[value(rename_all = "lowercase")]
65pub enum EchoRole {
66    /// Provides EchoService, waits for RPC calls.
67    Service,
68    /// Calls EchoService, sends echo RPC and exits.
69    #[default]
70    App,
71    /// Generates both EchoService provider and app projects.
72    Both,
73}
74
75#[derive(Debug, Clone, Serialize)]
76pub struct TemplateContext {
77    #[serde(rename = "PROJECT_NAME")]
78    pub project_name: String,
79    #[serde(rename = "PROJECT_NAME_SNAKE")]
80    pub project_name_snake: String,
81    #[serde(rename = "PROJECT_NAME_PASCAL")]
82    pub project_name_pascal: String,
83    #[serde(rename = "SIGNALING_URL")]
84    pub signaling_url: String,
85    #[serde(rename = "AIS_ENDPOINT_URL")]
86    pub ais_endpoint_url: String,
87    #[serde(rename = "MANUFACTURER")]
88    pub manufacturer: String,
89    #[serde(rename = "SERVICE_NAME")]
90    pub service_name: String,
91    #[serde(rename = "WORKLOAD_NAME")]
92    pub workload_name: String,
93    #[serde(rename = "ACTR_SWIFT_VERSION")]
94    pub actr_swift_version: String,
95    #[serde(rename = "ACTR_PROTOCOLS_VERSION")]
96    pub actr_protocols_version: String,
97    #[serde(rename = "ACTR_LOCAL_PATH")]
98    pub actr_local_path: Option<String>,
99
100    #[serde(rename = "REALM_ID")]
101    pub realm_id: u64,
102    #[serde(rename = "STUN_URLS")]
103    pub stun_urls: String,
104    #[serde(rename = "TURN_URLS")]
105    pub turn_urls: String,
106    #[serde(rename = "IS_SERVICE")]
107    pub is_service: bool,
108    /// True when this project is one half of a `role=both` generation pair.
109    #[serde(rename = "IS_BOTH")]
110    pub is_both: bool,
111}
112
113impl TemplateContext {
114    pub fn new(
115        project_name: &str,
116        signaling_url: &str,
117        manufacturer: &str,
118        service_name: &str,
119        is_service: bool,
120    ) -> Self {
121        let project_name_pascal = to_pascal_case(project_name);
122        Self {
123            project_name: project_name.to_string(),
124            project_name_snake: to_snake_case(project_name),
125            project_name_pascal: project_name_pascal.clone(),
126            signaling_url: signaling_url.to_string(),
127            ais_endpoint_url: derive_ais_endpoint_url(signaling_url),
128            manufacturer: manufacturer.to_string(),
129            service_name: service_name.to_string(),
130            workload_name: format!("{}Workload", project_name_pascal),
131            actr_swift_version: DEFAULT_ACTR_SWIFT_VERSION.to_string(),
132            actr_protocols_version: DEFAULT_ACTR_PROTOCOLS_VERSION.to_string(),
133            actr_local_path: resolve_actr_swift_local_path(),
134            realm_id: 2368266035,
135            stun_urls: r#"["stun:actrix1.develenv.com:3478"]"#.to_string(),
136            turn_urls: r#"["turn:actrix1.develenv.com:3478"]"#.to_string(),
137            is_service,
138            is_both: false,
139        }
140    }
141
142    pub async fn new_with_versions(
143        project_name: &str,
144        signaling_url: &str,
145        manufacturer: &str,
146        service_name: &str,
147        is_service: bool,
148    ) -> Self {
149        let mut ctx = Self::new(
150            project_name,
151            signaling_url,
152            manufacturer,
153            service_name,
154            is_service,
155        );
156
157        // Fetch latest versions in parallel with 5s timeout
158        let swift_task = crate::utils::fetch_latest_git_tag(
159            "https://github.com/actor-rtc/actr-swift",
160            &ctx.actr_swift_version,
161        );
162        let protocols_task = crate::utils::fetch_latest_git_tag(
163            "https://github.com/actor-rtc/actr-protocols-swift",
164            &ctx.actr_protocols_version,
165        );
166
167        let (swift_v, protocols_v) = tokio::join!(swift_task, protocols_task);
168
169        ctx.actr_swift_version = swift_v;
170        ctx.actr_protocols_version = protocols_v;
171
172        ctx
173    }
174}
175
176fn derive_ais_endpoint_url(signaling_url: &str) -> String {
177    let trimmed = signaling_url.trim_end_matches('/');
178    if trimmed.is_empty() {
179        return String::new();
180    }
181
182    let scheme_normalized = if let Some(rest) = trimmed.strip_prefix("wss://") {
183        format!("https://{rest}")
184    } else if let Some(rest) = trimmed.strip_prefix("ws://") {
185        format!("http://{rest}")
186    } else {
187        trimmed.to_string()
188    };
189
190    if let Some(prefix) = scheme_normalized.strip_suffix("/signaling/ws") {
191        format!("{prefix}/ais")
192    } else if let Some(prefix) = scheme_normalized.strip_suffix("/signaling") {
193        format!("{prefix}/ais")
194    } else if let Some(prefix) = scheme_normalized.strip_suffix("/ws") {
195        format!("{prefix}/ais")
196    } else {
197        format!("{scheme_normalized}/ais")
198    }
199}
200
201fn resolve_actr_swift_local_path() -> Option<String> {
202    let base = std::env::var("ACTR_SWIFT_LOCAL_PATH").ok()?;
203    let root = Path::new(&base);
204    let candidates: [PathBuf; 4] = [
205        root.to_path_buf(),
206        root.join("actr-swift"),
207        root.join("bindings/swift"),
208        root.join("actr/bindings/swift"),
209    ];
210
211    candidates
212        .into_iter()
213        .find(|path| path.join("Package.swift").is_file())
214        .map(|path| path.to_string_lossy().into_owned())
215}
216
217pub trait LangTemplate: Send + Sync {
218    fn load_files(
219        &self,
220        template_name: ProjectTemplateName,
221        context: &TemplateContext,
222    ) -> Result<HashMap<String, String>>;
223}
224
225pub struct ProjectTemplate {
226    name: ProjectTemplateName,
227    lang_template: Box<dyn LangTemplate>,
228}
229
230impl ProjectTemplate {
231    pub fn new(template_name: ProjectTemplateName, language: SupportedLanguage) -> Self {
232        let lang_template: Box<dyn LangTemplate> = match language {
233            SupportedLanguage::Swift => Box::new(SwiftTemplate),
234            SupportedLanguage::Kotlin => Box::new(KotlinTemplate),
235            SupportedLanguage::Python => Box::new(PythonTemplate),
236            SupportedLanguage::Rust => Box::new(RustTemplate),
237            SupportedLanguage::TypeScript => Box::new(TypeScriptTemplate),
238        };
239
240        Self {
241            name: template_name,
242            lang_template,
243        }
244    }
245
246    pub fn load_file(
247        fixture_path: &Path,
248        files: &mut HashMap<String, String>,
249        key: &str,
250    ) -> Result<()> {
251        let content = if fixture_path.exists() {
252            std::fs::read_to_string(fixture_path)?
253        } else {
254            // Read from embedded fixtures when running from packaged binaries.
255            let fixtures_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures");
256            let relative = fixture_path
257                .strip_prefix(&fixtures_root)
258                .map_err(|_| {
259                    ActrCliError::Io(std::io::Error::new(
260                        ErrorKind::NotFound,
261                        format!("Fixture not found: {}", fixture_path.display()),
262                    ))
263                })?
264                .to_string_lossy()
265                .replace('\\', "/");
266            let file = FixtureAssets::get(&relative).ok_or_else(|| {
267                ActrCliError::Io(std::io::Error::new(
268                    ErrorKind::NotFound,
269                    format!("Embedded fixture not found: {}", relative),
270                ))
271            })?;
272            std::str::from_utf8(file.data.as_ref())
273                .map_err(|error| {
274                    ActrCliError::Io(std::io::Error::new(
275                        ErrorKind::InvalidData,
276                        format!("Invalid UTF-8 fixture {}: {}", relative, error),
277                    ))
278                })?
279                .to_string()
280        };
281        files.insert(key.to_string(), content);
282        Ok(())
283    }
284
285    pub fn generate(&self, project_path: &Path, context: &TemplateContext) -> Result<()> {
286        let files = self.lang_template.load_files(self.name, context)?;
287        let handlebars = Handlebars::new();
288
289        for (file_path, content) in &files {
290            let rendered_path = handlebars.render_template(file_path, context)?;
291            let rendered_content = handlebars.render_template(content, context)?;
292
293            let full_path = project_path.join(&rendered_path);
294
295            // Create parent directories if they don't exist
296            if let Some(parent) = full_path.parent() {
297                std::fs::create_dir_all(parent)?;
298            }
299
300            std::fs::write(full_path, rendered_content)?;
301        }
302
303        Ok(())
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use tempfile::TempDir;
311
312    #[test]
313    fn test_template_context() {
314        let ctx = TemplateContext::new(
315            "my-chat-service",
316            "ws://localhost:8080",
317            DEFAULT_MANUFACTURER,
318            "echo-service",
319            false,
320        );
321        assert_eq!(ctx.project_name, "my-chat-service");
322        assert_eq!(ctx.project_name_snake, "my_chat_service");
323        assert_eq!(ctx.project_name_pascal, "MyChatService");
324        assert_eq!(ctx.workload_name, "MyChatServiceWorkload");
325        assert_eq!(ctx.signaling_url, "ws://localhost:8080");
326        assert_eq!(ctx.ais_endpoint_url, "http://localhost:8080/ais");
327        assert_eq!(ctx.actr_swift_version, DEFAULT_ACTR_SWIFT_VERSION);
328        assert_eq!(ctx.actr_protocols_version, DEFAULT_ACTR_PROTOCOLS_VERSION);
329    }
330
331    #[test]
332    fn test_project_template_new() {
333        let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
334        assert_eq!(template.name, ProjectTemplateName::Echo);
335    }
336
337    #[test]
338    fn test_project_template_generation() {
339        let temp_dir = TempDir::new().unwrap();
340        let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
341        let context = TemplateContext::new(
342            "test-app",
343            "ws://localhost:8080",
344            DEFAULT_MANUFACTURER,
345            "echo-service",
346            false,
347        );
348
349        template
350            .generate(temp_dir.path(), &context)
351            .expect("Failed to generate");
352
353        // Verify project.yml exists
354        assert!(temp_dir.path().join("project.yml").exists());
355        // Verify manifest.toml exists
356        assert!(temp_dir.path().join("manifest.toml").exists());
357        // Verify .gitignore exists
358        assert!(temp_dir.path().join(".gitignore").exists());
359        // Note: proto files are no longer created during init, they will be pulled via actr deps install
360        // Verify app directory exists
361        assert!(
362            temp_dir
363                .path()
364                .join("TestApp")
365                .join("TestApp.swift")
366                .exists()
367        );
368    }
369
370    #[test]
371    fn test_project_template_load_files() {
372        let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
373        let context = TemplateContext::new(
374            "test-app",
375            "ws://localhost:8080",
376            DEFAULT_MANUFACTURER,
377            "echo-service",
378            false,
379        );
380        let result = template
381            .lang_template
382            .load_files(ProjectTemplateName::Echo, &context);
383        assert!(result.is_ok());
384    }
385}