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