Skip to main content

actr_cli/commands/
doc.rs

1//! Doc command implementation - generate project documentation
2//!
3//! Now uses Handlebars templates and embedded assets for maintainability and portability.
4
5use 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    /// Output directory for documentation (defaults to "./docs")
26    #[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    // Project type flags
40    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        // Create output directory
112        std::fs::create_dir_all(output_dir)?;
113
114        // Load workload manifest
115        let config = if Path::new("manifest.toml").exists() {
116            Some(ConfigParser::from_manifest_file("manifest.toml")?)
117        } else {
118            None
119        };
120
121        // Initialize Handlebars
122        let hb = self.init_handlebars()?;
123
124        // Generate documentation files
125        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        // Helper to load template from assets
163        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        // Register partials
175        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        // Register templates
181        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    /// Generate project overview documentation
227    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        // Re-detect or use flags? I'll re-use the detection logic via flags for structure
238        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    /// Generate API documentation
320    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        // Collect proto files information
329        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                    // Use relative path for better context (e.g., "local/local.proto")
337                    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    /// Generate configuration documentation
363    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        // Generate configuration example
372        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        // 1. Try Cargo.toml
420        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        // 2. Try package.json
431        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        // 3. Try project.yml (XcodeGen)
439        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                // Check settings.MARKETING_VERSION
445                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                    // Handle numbers (e.g. 1.0)
453                    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        // 4. Try Gradle (Kotlin/Groovy)
464        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        // 5. Try pyproject.toml (PEP 621 / Poetry)
471        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}