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::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    /// Output directory for documentation (defaults to "./docs")
24    #[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    // Project type flags
38    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        // Create output directory
110        std::fs::create_dir_all(output_dir)?;
111
112        // Load project configuration
113        let config = if Path::new("Actr.toml").exists() {
114            Some(ConfigParser::from_file("Actr.toml")?)
115        } else {
116            None
117        };
118
119        // Initialize Handlebars
120        let hb = self.init_handlebars()?;
121
122        // Generate documentation files
123        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        // Helper to load template from assets
152        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        // Register partials
164        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        // Register templates
170        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    /// Generate project overview documentation
215    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        // Re-detect or use flags? I'll re-use the detection logic via flags for structure
226        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    /// Generate API documentation
291    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        // Collect proto files information
300        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                    // Use relative path for better context (e.g., "local/local.proto")
308                    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    /// Generate configuration documentation
334    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        // Generate configuration example
343        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        // 1. Try Cargo.toml
391        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        // 2. Try project.yml (XcodeGen)
402        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                // Check settings.MARKETING_VERSION
408                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                    // Handle numbers (e.g. 1.0)
416                    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        // 3. Try Gradle (Kotlin/Groovy)
427        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        // 4. Try pyproject.toml (PEP 621 / Poetry)
434        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}