1use crate::assets::TemplateAssets;
6use crate::commands::Command;
7use crate::error::{ActrCliError, Result};
8use actr_config::{Config, ConfigParser};
9use async_trait::async_trait;
10use clap::Args;
11use handlebars::Handlebars;
12use serde::Serialize;
13use std::path::{Path, PathBuf};
14use tracing::{debug, info, warn};
15use walkdir::WalkDir;
16
17#[derive(Args)]
18#[command(
19 about = "Generate project documentation",
20 long_about = "Generate static HTML documentation for the project, including project overview, API (Proto) reference, and configuration guide."
21)]
22pub struct DocCommand {
23 #[arg(short = 'o', long = "output")]
25 pub output_dir: Option<String>,
26}
27
28#[derive(Serialize)]
29struct BaseContext {
30 project_name: String,
31 project_version: String,
32 project_description: String,
33 page_title: String,
34 is_overview: bool,
35 is_api: bool,
36 is_config: bool,
37 is_rust: bool,
39 is_swift: bool,
40 is_kotlin: bool,
41 is_python: bool,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq)]
45enum ProjectType {
46 Rust,
47 Swift,
48 Kotlin,
49 Python,
50}
51
52impl ProjectType {
53 fn detect() -> Self {
54 if Path::new("project.yml").exists() || Path::new("Package.swift").exists() {
55 ProjectType::Swift
56 } else if Path::new("build.gradle.kts").exists() || Path::new("build.gradle").exists() {
57 ProjectType::Kotlin
58 } else if Path::new("pyproject.toml").exists() || Path::new("requirements.txt").exists() {
59 ProjectType::Python
60 } else {
61 ProjectType::Rust
62 }
63 }
64}
65
66#[derive(Serialize)]
67struct IndexContext {
68 #[serde(flatten)]
69 base: BaseContext,
70 project_structure: String,
71}
72
73#[derive(Serialize)]
74struct ApiContext {
75 #[serde(flatten)]
76 base: BaseContext,
77 proto_files: Vec<ProtoFile>,
78}
79
80#[derive(Serialize)]
81struct ProtoFile {
82 filename: String,
83 content: String,
84}
85
86#[derive(Serialize)]
87struct ConfigContext {
88 #[serde(flatten)]
89 base: BaseContext,
90 config_example: String,
91}
92
93#[async_trait]
94impl Command for DocCommand {
95 async fn execute(&self) -> Result<()> {
96 let output_dir = self.output_dir.as_deref().unwrap_or("docs");
97
98 if !Path::new("Actr.toml").exists()
99 && let Some(root) = Self::find_project_root()
100 {
101 return Err(ActrCliError::InvalidProject(format!(
102 "Actr.toml found at '{}'. Please run 'actr doc' from the project root.",
103 root.display()
104 )));
105 }
106
107 info!("š Generating project documentation to: {}", output_dir);
108
109 std::fs::create_dir_all(output_dir)?;
111
112 let config = if Path::new("Actr.toml").exists() {
114 Some(ConfigParser::from_file("Actr.toml")?)
115 } else {
116 None
117 };
118
119 let hb = self.init_handlebars()?;
121
122 self.generate_index_html(output_dir, &config, &hb).await?;
124 self.generate_api_html(output_dir, &config, &hb).await?;
125 self.generate_config_html(output_dir, &config, &hb).await?;
126
127 info!("ā
Documentation generated successfully");
128 info!("š Generated files:");
129 info!(" - {}/index.html (project overview)", output_dir);
130 info!(" - {}/api.html (API interface documentation)", output_dir);
131 info!(
132 " - {}/config.html (configuration documentation)",
133 output_dir
134 );
135
136 println!();
137 println!("š To preview the documentation locally:");
138 println!(" python3 -m http.server --directory {} 8080", output_dir);
139 println!(" # or");
140 println!(" npx http-server {} -p 8080", output_dir);
141 println!();
142
143 Ok(())
144 }
145}
146
147impl DocCommand {
148 fn init_handlebars(&self) -> Result<Handlebars<'static>> {
149 let mut hb = Handlebars::new();
150
151 let load_template = |name: &str| -> Result<String> {
153 let path = format!("doc/{}.hbs", name);
154 let file = TemplateAssets::get(&path).ok_or_else(|| {
155 ActrCliError::Internal(anyhow::anyhow!("Template not found: {}", path))
156 })?;
157 let content = std::str::from_utf8(file.data.as_ref())
158 .map_err(|e| ActrCliError::Internal(anyhow::anyhow!("Invalid UTF-8: {}", e)))?
159 .to_string();
160 Ok(content)
161 };
162
163 hb.register_partial("head", load_template("_head")?)
165 .map_err(|e| ActrCliError::Internal(anyhow::anyhow!(e)))?;
166 hb.register_partial("nav", load_template("_nav")?)
167 .map_err(|e| ActrCliError::Internal(anyhow::anyhow!(e)))?;
168
169 hb.register_template_string("index", load_template("index")?)
171 .map_err(|e| ActrCliError::Internal(anyhow::anyhow!(e)))?;
172 hb.register_template_string("api", load_template("api")?)
173 .map_err(|e| ActrCliError::Internal(anyhow::anyhow!(e)))?;
174 hb.register_template_string("config", load_template("config")?)
175 .map_err(|e| ActrCliError::Internal(anyhow::anyhow!(e)))?;
176
177 Ok(hb)
178 }
179
180 fn create_base_context(
181 &self,
182 config: &Option<Config>,
183 title: &str,
184 active_nav: &str,
185 ) -> BaseContext {
186 let project_name = config
187 .as_ref()
188 .map(|c| c.package.name.clone())
189 .unwrap_or_else(|| "Actor-RTC Project".to_string());
190 let project_version = Self::read_project_version().unwrap_or_else(|| "unknown".to_string());
191
192 let project_description = config
193 .as_ref()
194 .and_then(|c| c.package.description.clone())
195 .unwrap_or_else(|| "An Actor-RTC project".to_string());
196
197 let project_type = ProjectType::detect();
198
199 BaseContext {
200 project_name,
201 project_version,
202 project_description,
203 page_title: title.to_string(),
204 is_overview: active_nav == "overview",
205 is_api: active_nav == "api",
206 is_config: active_nav == "config",
207 is_rust: project_type == ProjectType::Rust,
208 is_swift: project_type == ProjectType::Swift,
209 is_kotlin: project_type == ProjectType::Kotlin,
210 is_python: project_type == ProjectType::Python,
211 }
212 }
213
214 async fn generate_index_html(
216 &self,
217 output_dir: &str,
218 config: &Option<Config>,
219 hb: &Handlebars<'_>,
220 ) -> Result<()> {
221 debug!("Generating index.html...");
222
223 let base_context = self.create_base_context(config, "Project Overview", "overview");
224 let project_name = &base_context.project_name;
225 let project_type = if base_context.is_swift {
227 ProjectType::Swift
228 } else if base_context.is_kotlin {
229 ProjectType::Kotlin
230 } else if base_context.is_python {
231 ProjectType::Python
232 } else {
233 ProjectType::Rust
234 };
235
236 let project_structure = self.detect_project_structure(project_name, project_type);
237
238 let context = IndexContext {
239 base: base_context,
240 project_structure,
241 };
242
243 let content = hb.render("index", &context)?;
244 let index_path = Path::new(output_dir).join("index.html");
245 std::fs::write(index_path, content)?;
246
247 Ok(())
248 }
249
250 fn detect_project_structure(&self, project_name: &str, project_type: ProjectType) -> String {
251 let mut tree = format!(
252 "{}/\nāāā Actr.toml # Project configuration\n",
253 project_name
254 );
255
256 match project_type {
257 ProjectType::Swift => {
258 tree.push_str("āāā project.yml # XcodeGen configuration\n");
259 tree.push_str(&format!("āāā {}/ # Source code\n", project_name));
260 tree.push_str("ā āāā App.swift # Entrypoint\n");
261 tree.push_str("ā āāā Generated/ # Generated code\n");
262 }
263 ProjectType::Kotlin => {
264 tree.push_str("āāā build.gradle.kts # Gradle configuration\n");
265 tree.push_str("āāā app/ # App module\n");
266 tree.push_str("ā āāā src/ # Source code\n");
267 tree.push_str("ā āāā main/java/ # Java/Kotlin source\n");
268 }
269 ProjectType::Python => {
270 tree.push_str("āāā main.py # Entrypoint\n");
271 tree.push_str("āāā generated/ # Generated code\n");
272 }
273 ProjectType::Rust => {
274 if Path::new("Cargo.toml").exists() {
275 tree.push_str("āāā Cargo.toml # Rust manifest\n");
276 }
277 tree.push_str("āāā src/ # Source code\n");
278 tree.push_str("ā āāā main.rs # Entrypoint\n");
279 tree.push_str("ā āāā generated/ # Generated code\n");
280 }
281 }
282
283 tree.push_str("āāā protos/\n");
284 tree.push_str("ā āāā local/ # Your service definitions\n");
285 tree.push_str("ā āāā remote/ # Installed dependencies\n");
286 tree.push_str("āāā docs/ # Project documentation");
287 tree
288 }
289
290 async fn generate_api_html(
292 &self,
293 output_dir: &str,
294 config: &Option<Config>,
295 hb: &Handlebars<'_>,
296 ) -> Result<()> {
297 debug!("Generating api.html...");
298
299 let mut proto_files = Vec::new();
301 let proto_dir = Path::new("protos");
302
303 if proto_dir.exists() {
304 for entry in WalkDir::new(proto_dir).into_iter().flatten() {
305 let path = entry.path();
306 if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("proto") {
307 let relative_path = path.strip_prefix(proto_dir).unwrap_or(path);
309 let filename = relative_path.to_string_lossy().to_string();
310
311 let content = std::fs::read_to_string(path).unwrap_or_else(|e| {
312 warn!("Failed to read proto file {:?}: {}", path, e);
313 String::new()
314 });
315 proto_files.push(ProtoFile { filename, content });
316 }
317 }
318 }
319 proto_files.sort_by(|a, b| a.filename.cmp(&b.filename));
320
321 let context = ApiContext {
322 base: self.create_base_context(config, "API Documentation", "api"),
323 proto_files,
324 };
325
326 let content = hb.render("api", &context)?;
327 let api_path = Path::new(output_dir).join("api.html");
328 std::fs::write(api_path, content)?;
329
330 Ok(())
331 }
332
333 async fn generate_config_html(
335 &self,
336 output_dir: &str,
337 config: &Option<Config>,
338 hb: &Handlebars<'_>,
339 ) -> Result<()> {
340 debug!("Generating config.html...");
341
342 let config_example = if Path::new("Actr.toml").exists() {
344 std::fs::read_to_string("Actr.toml").unwrap_or_default()
345 } else {
346 r#"edition = 1
347exports = []
348
349[package]
350name = "my-actor-service"
351description = "An Actor-RTC service"
352authors = []
353license = "Apache-2.0"
354tags = ["latest"]
355
356[package.actr_type]
357manufacturer = "my-company"
358name = "my-actor-service"
359
360[dependencies]
361
362[system.signaling]
363url = "ws://127.0.0.1:8080"
364
365[system.deployment]
366realm_id = 1001
367
368[system.discovery]
369visible = true
370
371[scripts]
372dev = "cargo run"
373test = "cargo test""#
374 .to_string()
375 };
376
377 let context = ConfigContext {
378 base: self.create_base_context(config, "Configuration", "config"),
379 config_example,
380 };
381
382 let content = hb.render("config", &context)?;
383 let config_path = Path::new(output_dir).join("config.html");
384 std::fs::write(config_path, content)?;
385
386 Ok(())
387 }
388
389 fn read_project_version() -> Option<String> {
390 if let Ok(cargo_toml) = std::fs::read_to_string("Cargo.toml")
392 && let Ok(value) = cargo_toml.parse::<toml::Value>()
393 && let Some(version) = value
394 .get("package")
395 .and_then(|package| package.get("version"))
396 .and_then(|version| version.as_str())
397 {
398 return Some(version.to_string());
399 }
400
401 if let Ok(project_yml) = std::fs::read_to_string("project.yml")
403 && let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(&project_yml)
404 && let Some(targets) = value.get("targets").and_then(|t| t.as_mapping())
405 {
406 for (_target_name, target_config) in targets {
407 if let Some(version) = target_config
409 .get("settings")
410 .and_then(|s| s.get("MARKETING_VERSION"))
411 {
412 if let Some(s) = version.as_str() {
413 return Some(s.to_string());
414 }
415 if let Some(f) = version.as_f64() {
417 return Some(f.to_string());
418 }
419 if let Some(i) = version.as_i64() {
420 return Some(i.to_string());
421 }
422 }
423 }
424 }
425
426 if let Some(version) = Self::read_gradle_version("build.gradle.kts")
428 .or_else(|| Self::read_gradle_version("build.gradle"))
429 {
430 return Some(version);
431 }
432
433 if let Ok(pyproject) = std::fs::read_to_string("pyproject.toml")
435 && let Ok(value) = pyproject.parse::<toml::Value>()
436 {
437 if let Some(version) = value
438 .get("project")
439 .and_then(|p| p.get("version"))
440 .and_then(|v| v.as_str())
441 {
442 return Some(version.to_string());
443 }
444 if let Some(version) = value
445 .get("tool")
446 .and_then(|t| t.get("poetry"))
447 .and_then(|p| p.get("version"))
448 .and_then(|v| v.as_str())
449 {
450 return Some(version.to_string());
451 }
452 }
453
454 None
455 }
456
457 fn read_gradle_version(path: &str) -> Option<String> {
458 let content = std::fs::read_to_string(path).ok()?;
459 for line in content.lines() {
460 let trimmed = line.trim();
461 if trimmed.is_empty()
462 || trimmed.starts_with("//")
463 || trimmed.starts_with('#')
464 || trimmed.starts_with("/*")
465 || trimmed.starts_with('*')
466 {
467 continue;
468 }
469
470 let rest = match trimmed.strip_prefix("version") {
471 Some(rest) => rest.trim_start(),
472 None => continue,
473 };
474 let rest = rest.strip_prefix('=').unwrap_or(rest).trim_start();
475
476 if let Some(rest) = rest.strip_prefix('"')
477 && let Some(end) = rest.find('"')
478 {
479 return Some(rest[..end].to_string());
480 }
481 if let Some(rest) = rest.strip_prefix('\'')
482 && let Some(end) = rest.find('\'')
483 {
484 return Some(rest[..end].to_string());
485 }
486 }
487
488 None
489 }
490
491 fn find_project_root() -> Option<PathBuf> {
492 let cwd = std::env::current_dir().ok()?;
493 for ancestor in cwd.ancestors() {
494 if ancestor.join("Actr.toml").exists() {
495 return Some(ancestor.to_path_buf());
496 }
497 }
498 None
499 }
500}