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