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_MANUFACTURER: &str = "acme";
28
29#[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize)]
63#[value(rename_all = "lowercase")]
64pub enum EchoRole {
65 Service,
67 #[default]
69 App,
70 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 #[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 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 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 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 assert!(temp_dir.path().join("project.yml").exists());
342 assert!(temp_dir.path().join("manifest.toml").exists());
344 assert!(temp_dir.path().join(".gitignore").exists());
346 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}