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 Empty,
37 #[value(name = "data-stream")]
38 DataStream,
39}
40
41impl ProjectTemplateName {
42 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize)]
64#[value(rename_all = "lowercase")]
65pub enum EchoRole {
66 Service,
68 #[default]
70 App,
71 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 #[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 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 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 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 assert!(temp_dir.path().join("project.yml").exists());
355 assert!(temp_dir.path().join("manifest.toml").exists());
357 assert!(temp_dir.path().join(".gitignore").exists());
359 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}