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