1pub 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#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize)]
62#[value(rename_all = "lowercase")]
63pub enum EchoRole {
64 Service,
66 #[default]
68 App,
69 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 #[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 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 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 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 assert!(temp_dir.path().join("project.yml").exists());
353 assert!(temp_dir.path().join("manifest.toml").exists());
355 assert!(temp_dir.path().join(".gitignore").exists());
357 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}