1pub mod kotlin;
4pub mod python;
5pub mod rust;
6pub mod swift;
7
8use self::kotlin::KotlinTemplate;
9use self::python::PythonTemplate;
10use self::rust::RustTemplate;
11use self::swift::SwiftTemplate;
12use crate::assets::FixtureAssets;
13use crate::error::{ActrCliError, Result};
14use crate::utils::{to_pascal_case, to_snake_case};
15use clap::ValueEnum;
16use handlebars::Handlebars;
17use serde::Serialize;
18use std::collections::HashMap;
19use std::io::ErrorKind;
20use std::path::Path;
21
22pub use crate::commands::SupportedLanguage;
23
24pub const DEFAULT_ACTR_SWIFT_VERSION: &str = "0.1.15";
25pub const DEFAULT_ACTR_PROTOCOLS_VERSION: &str = "0.1.2";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum, Serialize)]
29#[value(rename_all = "lowercase")]
30pub enum ProjectTemplateName {
31 #[default]
32 Echo,
33 #[value(name = "data-stream")]
34 DataStream,
35}
36
37impl ProjectTemplateName {
38 pub fn to_service_name(self) -> &'static str {
40 match self {
41 ProjectTemplateName::Echo => "echo-service",
42 ProjectTemplateName::DataStream => "data-stream-service",
43 }
44 }
45}
46
47impl std::fmt::Display for ProjectTemplateName {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 let pv = self
50 .to_possible_value()
51 .expect("ValueEnum variant must have a possible value");
52 write!(f, "{}", pv.get_name())
53 }
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct TemplateContext {
58 #[serde(rename = "PROJECT_NAME")]
59 pub project_name: String,
60 #[serde(rename = "PROJECT_NAME_SNAKE")]
61 pub project_name_snake: String,
62 #[serde(rename = "PROJECT_NAME_PASCAL")]
63 pub project_name_pascal: String,
64 #[serde(rename = "SIGNALING_URL")]
65 pub signaling_url: String,
66 #[serde(rename = "MANUFACTURER")]
67 pub manufacturer: String,
68 #[serde(rename = "SERVICE_NAME")]
69 pub service_name: String,
70 #[serde(rename = "WORKLOAD_NAME")]
71 pub workload_name: String,
72 #[serde(rename = "ACTR_SWIFT_VERSION")]
73 pub actr_swift_version: String,
74 #[serde(rename = "ACTR_PROTOCOLS_VERSION")]
75 pub actr_protocols_version: String,
76 #[serde(rename = "ACTR_LOCAL_PATH")]
77 pub actr_local_path: Option<String>,
78 #[serde(rename = "REALM_ID")]
79 pub realm_id: u64,
80 #[serde(rename = "STUN_URLS")]
81 pub stun_urls: String,
82 #[serde(rename = "TURN_URLS")]
83 pub turn_urls: String,
84}
85
86impl TemplateContext {
87 pub fn new(project_name: &str, signaling_url: &str, service_name: &str) -> Self {
88 let project_name_pascal = to_pascal_case(project_name);
89 Self {
90 project_name: project_name.to_string(),
91 project_name_snake: to_snake_case(project_name),
92 project_name_pascal: project_name_pascal.clone(),
93 signaling_url: signaling_url.to_string(),
94 manufacturer: "unknown".to_string(),
95 service_name: service_name.to_string(),
96 workload_name: format!("{}Workload", project_name_pascal),
97 actr_swift_version: DEFAULT_ACTR_SWIFT_VERSION.to_string(),
98 actr_protocols_version: DEFAULT_ACTR_PROTOCOLS_VERSION.to_string(),
99 actr_local_path: std::env::var("ACTR_SWIFT_LOCAL_PATH").ok(),
100 realm_id: 2368266035,
101 stun_urls: r#"["stun:actrix1.develenv.com:3478"]"#.to_string(),
102 turn_urls: r#"["turn:actrix1.develenv.com:3478"]"#.to_string(),
103 }
104 }
105
106 pub async fn new_with_versions(
107 project_name: &str,
108 signaling_url: &str,
109 service_name: &str,
110 ) -> Self {
111 let mut ctx = Self::new(project_name, signaling_url, service_name);
112
113 let swift_task = crate::utils::fetch_latest_git_tag(
115 "https://github.com/actor-rtc/actr-swift",
116 &ctx.actr_swift_version,
117 );
118 let protocols_task = crate::utils::fetch_latest_git_tag(
119 "https://github.com/actor-rtc/actr-protocols-swift",
120 &ctx.actr_protocols_version,
121 );
122
123 let (swift_v, protocols_v) = tokio::join!(swift_task, protocols_task);
124
125 ctx.actr_swift_version = swift_v;
126 ctx.actr_protocols_version = protocols_v;
127
128 ctx
129 }
130}
131
132pub trait LangTemplate: Send + Sync {
133 fn load_files(&self, template_name: ProjectTemplateName) -> Result<HashMap<String, String>>;
134}
135
136pub struct ProjectTemplate {
137 name: ProjectTemplateName,
138 lang_template: Box<dyn LangTemplate>,
139}
140
141impl ProjectTemplate {
142 pub fn new(template_name: ProjectTemplateName, language: SupportedLanguage) -> Self {
143 let lang_template: Box<dyn LangTemplate> = match language {
144 SupportedLanguage::Swift => Box::new(SwiftTemplate),
145 SupportedLanguage::Kotlin => Box::new(KotlinTemplate),
146 SupportedLanguage::Python => Box::new(PythonTemplate),
147 SupportedLanguage::Rust => Box::new(RustTemplate),
148 };
149
150 Self {
151 name: template_name,
152 lang_template,
153 }
154 }
155
156 pub fn load_file(
157 fixture_path: &Path,
158 files: &mut HashMap<String, String>,
159 key: &str,
160 ) -> Result<()> {
161 let content = if fixture_path.exists() {
162 std::fs::read_to_string(fixture_path)?
163 } else {
164 let fixtures_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures");
166 let relative = fixture_path
167 .strip_prefix(&fixtures_root)
168 .map_err(|_| {
169 ActrCliError::Io(std::io::Error::new(
170 ErrorKind::NotFound,
171 format!("Fixture not found: {}", fixture_path.display()),
172 ))
173 })?
174 .to_string_lossy()
175 .replace('\\', "/");
176 let file = FixtureAssets::get(&relative).ok_or_else(|| {
177 ActrCliError::Io(std::io::Error::new(
178 ErrorKind::NotFound,
179 format!("Embedded fixture not found: {}", relative),
180 ))
181 })?;
182 std::str::from_utf8(file.data.as_ref())
183 .map_err(|error| {
184 ActrCliError::Io(std::io::Error::new(
185 ErrorKind::InvalidData,
186 format!("Invalid UTF-8 fixture {}: {}", relative, error),
187 ))
188 })?
189 .to_string()
190 };
191 files.insert(key.to_string(), content);
192 Ok(())
193 }
194
195 pub fn generate(&self, project_path: &Path, context: &TemplateContext) -> Result<()> {
196 let files = self.lang_template.load_files(self.name)?;
197 let handlebars = Handlebars::new();
198
199 for (file_path, content) in &files {
200 let rendered_path = handlebars.render_template(file_path, context)?;
201 let rendered_content = handlebars.render_template(content, context)?;
202
203 let full_path = project_path.join(&rendered_path);
204
205 if let Some(parent) = full_path.parent() {
207 std::fs::create_dir_all(parent)?;
208 }
209
210 std::fs::write(full_path, rendered_content)?;
211 }
212
213 Ok(())
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use tempfile::TempDir;
221
222 #[test]
223 fn test_template_context() {
224 let ctx = TemplateContext::new("my-chat-service", "ws://localhost:8080", "echo-service");
225 assert_eq!(ctx.project_name, "my-chat-service");
226 assert_eq!(ctx.project_name_snake, "my_chat_service");
227 assert_eq!(ctx.project_name_pascal, "MyChatService");
228 assert_eq!(ctx.workload_name, "MyChatServiceWorkload");
229 assert_eq!(ctx.signaling_url, "ws://localhost:8080");
230 assert_eq!(ctx.actr_swift_version, DEFAULT_ACTR_SWIFT_VERSION);
231 assert_eq!(ctx.actr_protocols_version, DEFAULT_ACTR_PROTOCOLS_VERSION);
232 }
233
234 #[test]
235 fn test_project_template_new() {
236 let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
237 assert_eq!(template.name, ProjectTemplateName::Echo);
238 }
239
240 #[test]
241 fn test_project_template_generation() {
242 let temp_dir = TempDir::new().unwrap();
243 let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
244 let context = TemplateContext::new("test-app", "ws://localhost:8080", "echo-service");
245
246 template
247 .generate(temp_dir.path(), &context)
248 .expect("Failed to generate");
249
250 assert!(temp_dir.path().join("project.yml").exists());
252 assert!(temp_dir.path().join("Actr.toml").exists());
254 assert!(temp_dir.path().join(".gitignore").exists());
256 assert!(
259 temp_dir
260 .path()
261 .join("TestApp")
262 .join("TestApp.swift")
263 .exists()
264 );
265 }
266
267 #[test]
268 fn test_project_template_load_files() {
269 let template = ProjectTemplate::new(ProjectTemplateName::Echo, SupportedLanguage::Swift);
270 let result = template.lang_template.load_files(ProjectTemplateName::Echo);
271 assert!(result.is_ok());
272 }
273}