diff --git a/src/commands/docs.rs b/src/commands/docs.rs index 0db5d17b..0bca6f4a 100644 --- a/src/commands/docs.rs +++ b/src/commands/docs.rs @@ -1,20 +1,4 @@ -//! `starforge docs` — Contract Documentation Generator -//! -//! Subcommands: -//! - `generate` Generate docs from a contract (by ID / metadata) -//! - `extract` Extract doc comments directly from a Rust source file/dir -//! - `show` Display stored docs in the terminal -//! - `list` List all documented contracts -//! - `search` Full-text search across all docs -//! - `versions` Show version history for a contract -//! - `export` Render stored docs as Markdown (stdout) -//! - `html` Generate/update the HTML documentation site -//! - `api-ref` Generate a machine-readable API reference (JSON + Markdown) -//! - `publish` Run the full build + publish pipeline - -use crate::utils::{ - doc_api_ref, doc_extractor, doc_html, doc_publisher, doc_templates, docs, print as p, -}; +use crate::utils::{doc_generator, docs, print as p}; use anyhow::Result; use clap::Subcommand; use colored::Colorize; @@ -84,63 +68,64 @@ pub enum DocsCommands { #[arg(long)] version: Option, }, - - /// Generate (or update) the HTML documentation site + /// Extract doc comments from a Soroban contract source file + Extract { + /// Path to the contract source file (.rs) or directory + source: String, + /// Output file path for extracted JSON (stdout if omitted) + #[arg(long)] + output: Option, + /// Output format: json or markdown (default: json) + #[arg(long, default_value = "json")] + format: String, + }, + /// Generate HTML documentation site from a contract source file Html { - /// Contract ID to render + /// Contract ID (used for output directory naming) contract: String, - /// Directory to write HTML files into - #[arg(long, default_value = "docs-site")] - output: PathBuf, - /// Optional custom template directory + /// Contract display name + #[arg(long)] + name: String, + /// Path to the contract source file (.rs) + #[arg(long)] + source: String, + /// Output directory for the generated HTML site #[arg(long)] - templates: Option, + output_dir: Option, + /// Custom template directory (overrides built-in templates) + #[arg(long)] + template_dir: Option, }, - - /// Generate a machine-readable API reference (JSON + Markdown) - #[command(name = "api-ref")] + /// Generate an API reference (JSON + Markdown) from a contract source file ApiRef { /// Contract ID contract: String, - /// Directory to write reference files into - #[arg(long, default_value = "docs-site")] - output: PathBuf, - /// Only emit JSON (skip Markdown) + /// Contract display name + #[arg(long)] + name: String, + /// Path to the contract source file (.rs) #[arg(long)] - json_only: bool, - /// Only emit Markdown (skip JSON) + source: String, + /// Documentation version + #[arg(long, default_value = "1.0.0")] + version: String, + /// Output directory (defaults to ~/.starforge/docs//) #[arg(long)] - md_only: bool, + output_dir: Option, }, - - /// Run the full build + publish pipeline for a contract + /// Publish generated HTML documentation to a destination Publish { /// Contract ID contract: String, - /// Intermediate build directory - #[arg(long, default_value = "docs-build")] - build_dir: PathBuf, - /// Publish target: local path, github-pages, or http URL - #[arg(long, default_value = "local")] - target: String, - /// Destination for local publish - #[arg(long)] - dest: Option, - /// Local git repo path for GitHub Pages publish - #[arg(long)] - repo: Option, - /// HTTP endpoint for custom HTTP publish + /// Source directory containing the generated HTML site #[arg(long)] - endpoint: Option, - /// Bearer token for HTTP publish + source_dir: Option, + /// Destination path or remote rsync target (e.g. user@host:/var/www/docs) #[arg(long)] - token: Option, - /// Include JSON API reference in the published output + dest: Option, + /// Also write a deploy.sh script for manual deployment #[arg(long)] - api_json: bool, - /// Include Markdown API reference in the published output - #[arg(long)] - api_md: bool, + generate_script: bool, }, } @@ -165,33 +150,31 @@ pub async fn handle(cmd: DocsCommands) -> Result<()> { DocsCommands::Search { query } => search(query), DocsCommands::Versions { contract } => versions(contract), DocsCommands::Export { contract, version } => export(contract, version), - + DocsCommands::Extract { + source, + output, + format, + } => extract(source, output, format), DocsCommands::Html { contract, - output, - templates, - } => html(contract, output, templates), - + name, + source, + output_dir, + template_dir, + } => generate_html(contract, name, source, output_dir, template_dir), DocsCommands::ApiRef { contract, - output, - json_only, - md_only, - } => api_ref(contract, output, json_only, md_only), - + name, + source, + version, + output_dir, + } => generate_api_ref(contract, name, source, version, output_dir), DocsCommands::Publish { contract, - build_dir, - target, + source_dir, dest, - repo, - endpoint, - token, - api_json, - api_md, - } => publish( - contract, build_dir, target, dest, repo, endpoint, token, api_json, api_md, - ), + generate_script, + } => publish(contract, source_dir, dest, generate_script), } } @@ -734,3 +717,236 @@ fn publish( p::info(&result.message); Ok(()) } + +fn extract(source: String, output: Option, format: String) -> Result<()> { + p::header("Documentation — Extract Doc Comments"); + + let source_path = PathBuf::from(&source); + p::step(1, 3, &format!("Reading source: {}", source)); + + let extracted = if source_path.is_dir() { + // Merge docs from all .rs files in the directory + let mut merged = doc_generator::ExtractedDocs { + module_doc: String::new(), + functions: Vec::new(), + structs: Vec::new(), + enums: Vec::new(), + constants: Vec::new(), + source_path: source.clone(), + }; + for entry in std::fs::read_dir(&source_path)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("rs") { + let docs = doc_generator::DocCommentExtractor::extract_from_file(&path)?; + merged.functions.extend(docs.functions); + merged.structs.extend(docs.structs); + merged.enums.extend(docs.enums); + merged.constants.extend(docs.constants); + if merged.module_doc.is_empty() { + merged.module_doc = docs.module_doc; + } + } + } + merged + } else { + doc_generator::DocCommentExtractor::extract_from_file(&source_path)? + }; + + p::step(2, 3, "Formatting output..."); + + let content = match format.as_str() { + "markdown" | "md" => { + let api = doc_generator::ApiReferenceGenerator::from_extracted( + &extracted, + &source, + &source, + "extracted", + ); + doc_generator::ApiReferenceGenerator::render_markdown(&api) + } + _ => serde_json::to_string_pretty(&extracted)?, + }; + + p::step(3, 3, "Writing output..."); + + if let Some(out_path) = output { + std::fs::write(&out_path, &content)?; + p::success(&format!("Extracted docs written to '{}'", out_path)); + } else { + println!("{}", content); + } + + println!(); + p::kv("Functions found", &extracted.functions.len().to_string()); + p::kv("Structs found", &extracted.structs.len().to_string()); + p::kv("Enums found", &extracted.enums.len().to_string()); + p::kv( + "Public functions", + &extracted + .functions + .iter() + .filter(|f| f.visibility == doc_generator::Visibility::Public) + .count() + .to_string(), + ); + + Ok(()) +} + +fn generate_html( + contract: String, + name: String, + source: String, + output_dir: Option, + template_dir: Option, +) -> Result<()> { + p::header("Documentation — Generate HTML Site"); + + p::step(1, 4, &format!("Extracting doc comments from '{}'", source)); + let source_path = PathBuf::from(&source); + let extracted = doc_generator::DocCommentExtractor::extract_from_file(&source_path)?; + + let out_dir = output_dir + .map(PathBuf::from) + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".starforge") + .join("docs") + .join("html") + .join(&contract) + }); + + p::step(2, 4, "Initialising template engine..."); + let mut generator = doc_generator::HtmlDocGenerator::new(); + if let Some(tmpl_dir) = template_dir { + generator = generator.with_template_dir(&PathBuf::from(tmpl_dir))?; + } + + p::step(3, 4, &format!("Generating HTML site in '{}'", out_dir.display())); + generator.generate_site(&extracted, &name, &contract, &out_dir)?; + + p::step(4, 4, "Writing publish manifest..."); + doc_generator::DocPublisher::write_manifest(&out_dir, &contract, "latest")?; + + println!(); + p::success(&format!("HTML documentation generated for '{}'", name)); + p::kv("Contract", &contract); + p::kv("Output", &out_dir.display().to_string()); + p::kv("Functions documented", &extracted.functions.len().to_string()); + p::info(&format!( + "Open '{}' to view the documentation.", + out_dir.join("index.html").display() + )); + + Ok(()) +} + +fn generate_api_ref( + contract: String, + name: String, + source: String, + version: String, + output_dir: Option, +) -> Result<()> { + p::header("Documentation — Generate API Reference"); + + p::step(1, 3, &format!("Extracting from '{}'", source)); + let source_path = PathBuf::from(&source); + let extracted = doc_generator::DocCommentExtractor::extract_from_file(&source_path)?; + + p::step(2, 3, "Building API reference..."); + let api_ref = doc_generator::ApiReferenceGenerator::from_extracted( + &extracted, + &contract, + &name, + &version, + ); + + let out_dir = output_dir + .map(PathBuf::from) + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".starforge") + .join("docs") + .join(&contract) + }); + + std::fs::create_dir_all(&out_dir)?; + + p::step(3, 3, "Writing JSON and Markdown..."); + let json_path = out_dir.join("api-reference.json"); + let md_path = out_dir.join("api-reference.md"); + + doc_generator::ApiReferenceGenerator::save_json(&api_ref, &json_path)?; + std::fs::write(&md_path, doc_generator::ApiReferenceGenerator::render_markdown(&api_ref))?; + + println!(); + p::success(&format!("API reference generated for '{}' v{}", name, version)); + p::kv("JSON", &json_path.display().to_string()); + p::kv("Markdown", &md_path.display().to_string()); + p::kv("Functions", &api_ref.functions.len().to_string()); + p::kv("Events", &api_ref.events.len().to_string()); + + Ok(()) +} + +fn publish( + contract: String, + source_dir: Option, + dest: Option, + generate_script: bool, +) -> Result<()> { + p::header("Documentation — Publish"); + + let src = source_dir + .map(PathBuf::from) + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".starforge") + .join("docs") + .join("html") + .join(&contract) + }); + + if !src.exists() { + p::error(&format!( + "Source directory '{}' does not exist. Run `starforge docs html` first.", + src.display() + )); + return Ok(()); + } + + p::step(1, 3, &format!("Source: {}", src.display())); + + if let Some(ref destination) = dest { + p::step(2, 3, &format!("Copying to '{}'", destination)); + let dest_path = PathBuf::from(destination); + doc_generator::DocPublisher::publish_to_dir(&src, &dest_path)?; + p::success(&format!("Documentation published to '{}'", destination)); + } else { + p::step(2, 3, "No --dest specified; skipping file copy"); + } + + p::step(3, 3, "Finalising..."); + doc_generator::DocPublisher::write_manifest(&src, &contract, "latest")?; + + if generate_script { + let endpoint = dest.as_deref().unwrap_or("user@host:/var/www/docs"); + let script = doc_generator::DocPublisher::generate_deploy_script(&src, endpoint)?; + p::kv("Deploy script", &script.display().to_string()); + } + + println!(); + p::kv("Contract", &contract); + p::kv("Source", &src.display().to_string()); + if let Some(d) = &dest { + p::kv("Destination", d); + } + p::info("Manifest written to manifest.json in the source directory."); + + Ok(()) +} diff --git a/src/utils/doc_generator.rs b/src/utils/doc_generator.rs new file mode 100644 index 00000000..e1cb7f06 --- /dev/null +++ b/src/utils/doc_generator.rs @@ -0,0 +1,1508 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +// ── Extracted source types ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedDocs { + pub module_doc: String, + pub functions: Vec, + pub structs: Vec, + pub enums: Vec, + pub constants: Vec, + pub source_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedFn { + pub name: String, + pub doc_comment: String, + pub signature: String, + pub visibility: Visibility, + pub params: Vec, + pub return_type: Option, + pub examples: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedStruct { + pub name: String, + pub doc_comment: String, + pub fields: Vec, + pub visibility: Visibility, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedEnum { + pub name: String, + pub doc_comment: String, + pub variants: Vec, + pub visibility: Visibility, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedConst { + pub name: String, + pub doc_comment: String, + pub ty: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedParam { + pub name: String, + pub ty: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedField { + pub name: String, + pub ty: String, + pub doc_comment: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedVariant { + pub name: String, + pub doc_comment: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Visibility { + Public, + Private, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodeExample { + pub lang: String, + pub code: String, +} + +// ── Doc comment extractor ───────────────────────────────────────────────────── + +pub struct DocCommentExtractor; + +impl DocCommentExtractor { + pub fn extract_from_file(path: &Path) -> Result { + let source = + fs::read_to_string(path).with_context(|| format!("Cannot read {}", path.display()))?; + let mut docs = Self::extract_from_source(&source); + docs.source_path = path.display().to_string(); + Ok(docs) + } + + pub fn extract_from_source(source: &str) -> ExtractedDocs { + let mut docs = ExtractedDocs { + module_doc: String::new(), + functions: Vec::new(), + structs: Vec::new(), + enums: Vec::new(), + constants: Vec::new(), + source_path: String::new(), + }; + + let lines: Vec<&str> = source.lines().collect(); + let mut pending_doc: Vec = Vec::new(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i].trim(); + + // Module-level doc comment `//!` + if line.starts_with("//!") { + docs.module_doc.push_str(&strip_prefix(line, "//!")); + docs.module_doc.push('\n'); + i += 1; + continue; + } + + // Item doc comment `///` + if line.starts_with("///") { + pending_doc.push(strip_prefix(line, "///")); + i += 1; + continue; + } + + // Attributes (#[...]) — skip but don't discard pending_doc + if line.starts_with("#[") || line.starts_with("#![") { + i += 1; + continue; + } + + // Function definition + if let Some(fn_name) = parse_fn_name(line) { + let doc_text = pending_doc.join("\n"); + let examples = ExampleExtractor::extract_from_comment(&doc_text); + let (params, return_type) = parse_fn_signature(line); + let vis = if line.contains("pub ") { + Visibility::Public + } else { + Visibility::Private + }; + docs.functions.push(ExtractedFn { + name: fn_name, + doc_comment: doc_text, + signature: line.to_string(), + visibility: vis, + params, + return_type, + examples, + }); + pending_doc.clear(); + i += 1; + continue; + } + + // Struct definition + if let Some(struct_name) = parse_struct_name(line) { + let doc_text = pending_doc.join("\n"); + let vis = if line.contains("pub ") { + Visibility::Public + } else { + Visibility::Private + }; + let fields = extract_struct_fields(&lines, i); + docs.structs.push(ExtractedStruct { + name: struct_name, + doc_comment: doc_text, + fields, + visibility: vis, + }); + pending_doc.clear(); + i += 1; + continue; + } + + // Enum definition + if let Some(enum_name) = parse_enum_name(line) { + let doc_text = pending_doc.join("\n"); + let vis = if line.contains("pub ") { + Visibility::Public + } else { + Visibility::Private + }; + let variants = extract_enum_variants(&lines, i); + docs.enums.push(ExtractedEnum { + name: enum_name, + doc_comment: doc_text, + variants, + visibility: vis, + }); + pending_doc.clear(); + i += 1; + continue; + } + + // Const / static + if let Some((const_name, const_ty, const_val)) = parse_const(line) { + let doc_text = pending_doc.join("\n"); + docs.constants.push(ExtractedConst { + name: const_name, + doc_comment: doc_text, + ty: const_ty, + value: const_val, + }); + pending_doc.clear(); + i += 1; + continue; + } + + // Any other non-blank line resets pending doc + if !line.is_empty() { + pending_doc.clear(); + } + + i += 1; + } + + docs + } +} + +fn strip_prefix<'a>(line: &'a str, prefix: &str) -> String { + line.strip_prefix(prefix) + .map(|s| s.trim_start().to_string()) + .unwrap_or_default() +} + +fn parse_fn_name(line: &str) -> Option { + let stripped = line + .trim_start_matches("pub(crate) ") + .trim_start_matches("pub ") + .trim_start_matches("async ") + .trim_start_matches("unsafe "); + if stripped.starts_with("fn ") { + let rest = &stripped["fn ".len()..]; + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + return Some(name); + } + } + None +} + +fn parse_struct_name(line: &str) -> Option { + let stripped = line + .trim_start_matches("pub(crate) ") + .trim_start_matches("pub "); + if stripped.starts_with("struct ") { + let rest = &stripped["struct ".len()..]; + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + return Some(name); + } + } + None +} + +fn parse_enum_name(line: &str) -> Option { + let stripped = line + .trim_start_matches("pub(crate) ") + .trim_start_matches("pub "); + if stripped.starts_with("enum ") { + let rest = &stripped["enum ".len()..]; + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + return Some(name); + } + } + None +} + +fn parse_const(line: &str) -> Option<(String, String, String)> { + let stripped = line + .trim_start_matches("pub(crate) ") + .trim_start_matches("pub "); + if stripped.starts_with("const ") || stripped.starts_with("static ") { + // const NAME: Type = value; + if let Some(colon_pos) = stripped.find(':') { + let name_part = &stripped[stripped.find(' ').unwrap_or(0) + 1..colon_pos]; + let name = name_part.trim().to_string(); + let rest = &stripped[colon_pos + 1..]; + if let Some(eq_pos) = rest.find('=') { + let ty = rest[..eq_pos].trim().to_string(); + let val = rest[eq_pos + 1..] + .trim() + .trim_end_matches(';') + .to_string(); + if !name.is_empty() { + return Some((name, ty, val)); + } + } + } + } + None +} + +fn parse_fn_signature(line: &str) -> (Vec, Option) { + let mut params = Vec::new(); + + // Extract return type after `->` + let return_type = if let Some(arrow_pos) = line.find("->") { + let ret = line[arrow_pos + 2..] + .split('{') + .next() + .unwrap_or("") + .trim() + .to_string(); + if !ret.is_empty() { + Some(ret) + } else { + None + } + } else { + None + }; + + // Extract params between `(` and `)` + if let (Some(open), Some(close)) = (line.find('('), line.rfind(')')) { + if open < close { + let param_str = &line[open + 1..close]; + for part in param_str.split(',') { + let trimmed = part.trim(); + if trimmed.is_empty() || trimmed == "&self" || trimmed == "&mut self" || trimmed == "self" { + continue; + } + if let Some(colon_pos) = trimmed.find(':') { + let name = trimmed[..colon_pos].trim().trim_start_matches('&').trim_start_matches("mut ").to_string(); + let ty = trimmed[colon_pos + 1..].trim().to_string(); + if !name.is_empty() { + params.push(ExtractedParam { name, ty }); + } + } + } + } + } + + (params, return_type) +} + +fn extract_struct_fields(lines: &[&str], start: usize) -> Vec { + let mut fields = Vec::new(); + let mut in_struct = false; + let mut depth = 0; + let mut pending_field_doc = Vec::new(); + + for line in &lines[start..] { + let trimmed = line.trim(); + + if !in_struct { + if trimmed.contains('{') { + in_struct = true; + depth += trimmed.chars().filter(|&c| c == '{').count(); + depth -= trimmed.chars().filter(|&c| c == '}').count(); + } + continue; + } + + depth += trimmed.chars().filter(|&c| c == '{').count(); + depth -= trimmed.chars().filter(|&c| c == '}').count(); + + if depth == 0 { + break; + } + + if trimmed.starts_with("///") { + pending_field_doc.push(strip_prefix(trimmed, "///")); + continue; + } + + // field: Type, + if let Some(colon_pos) = trimmed.find(':') { + let field_part = trimmed[..colon_pos] + .trim_start_matches("pub(crate) ") + .trim_start_matches("pub ") + .trim(); + let type_part = trimmed[colon_pos + 1..] + .trim() + .trim_end_matches(',') + .to_string(); + if !field_part.is_empty() + && !field_part.starts_with("//") + && field_part.chars().next().map_or(false, |c| c.is_alphabetic() || c == '_') + { + fields.push(ExtractedField { + name: field_part.to_string(), + ty: type_part, + doc_comment: pending_field_doc.join("\n"), + }); + } + } + if !trimmed.starts_with("///") { + pending_field_doc.clear(); + } + } + + fields +} + +fn extract_enum_variants(lines: &[&str], start: usize) -> Vec { + let mut variants = Vec::new(); + let mut in_enum = false; + let mut depth = 0; + let mut pending_variant_doc = Vec::new(); + + for line in &lines[start..] { + let trimmed = line.trim(); + + if !in_enum { + if trimmed.contains('{') { + in_enum = true; + depth += trimmed.chars().filter(|&c| c == '{').count(); + depth -= trimmed.chars().filter(|&c| c == '}').count(); + } + continue; + } + + depth += trimmed.chars().filter(|&c| c == '{').count(); + depth -= trimmed.chars().filter(|&c| c == '}').count(); + + if depth == 0 { + break; + } + + if trimmed.starts_with("///") { + pending_variant_doc.push(strip_prefix(trimmed, "///")); + continue; + } + + // Variant name (possibly followed by `(...)`, `{ ... }`, or just `,`) + let variant_name: String = trimmed + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !variant_name.is_empty() + && variant_name.chars().next().map_or(false, |c| c.is_uppercase()) + { + variants.push(ExtractedVariant { + name: variant_name, + doc_comment: pending_variant_doc.join("\n"), + }); + } + if !trimmed.starts_with("///") { + pending_variant_doc.clear(); + } + } + + variants +} + +// ── Example extractor ───────────────────────────────────────────────────────── + +pub struct ExampleExtractor; + +impl ExampleExtractor { + pub fn extract_from_comment(comment: &str) -> Vec { + let mut examples = Vec::new(); + let mut in_fence = false; + let mut lang = String::new(); + let mut code_lines: Vec = Vec::new(); + + for line in comment.lines() { + let trimmed = line.trim(); + if !in_fence { + if trimmed.starts_with("```") { + lang = trimmed[3..].trim().to_string(); + if lang.is_empty() { + lang = "rust".to_string(); + } + in_fence = true; + code_lines.clear(); + } + } else if trimmed == "```" { + examples.push(CodeExample { + lang: lang.clone(), + code: code_lines.join("\n"), + }); + in_fence = false; + code_lines.clear(); + } else { + code_lines.push(line.to_string()); + } + } + + examples + } + + pub fn extract_from_file(path: &Path) -> Result> { + let source = fs::read_to_string(path)?; + let mut examples = Vec::new(); + + // Extract all doc comments from the file and pull examples out + for chunk in source.split("///") { + let e = Self::extract_from_comment(chunk); + examples.extend(e); + } + + Ok(examples) + } +} + +// ── Template engine ─────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct DocTemplate { + pub name: String, + content: String, +} + +impl DocTemplate { + pub fn new(name: &str, content: &str) -> Self { + Self { + name: name.to_string(), + content: content.to_string(), + } + } + + pub fn render(&self, ctx: &HashMap) -> String { + let mut out = self.content.clone(); + for (key, value) in ctx { + out = out.replace(&format!("{{{{{}}}}}", key), value); + } + out + } +} + +pub struct TemplateEngine { + templates: HashMap, +} + +impl TemplateEngine { + pub fn new() -> Self { + let mut engine = Self { + templates: HashMap::new(), + }; + engine.load_builtin_templates(); + engine + } + + fn load_builtin_templates(&mut self) { + self.templates + .insert("base".to_string(), DocTemplate::new("base", BASE_TEMPLATE)); + self.templates.insert( + "contract_page".to_string(), + DocTemplate::new("contract_page", CONTRACT_PAGE_TEMPLATE), + ); + self.templates.insert( + "api_reference".to_string(), + DocTemplate::new("api_reference", API_REFERENCE_TEMPLATE), + ); + self.templates.insert( + "index".to_string(), + DocTemplate::new("index", INDEX_TEMPLATE), + ); + } + + pub fn load_from_dir(&mut self, dir: &Path) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("html") { + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string(); + let content = fs::read_to_string(&path)?; + self.templates + .insert(name.clone(), DocTemplate::new(&name, &content)); + } + } + Ok(()) + } + + pub fn render(&self, template_name: &str, ctx: &HashMap) -> Result { + let tmpl = self + .templates + .get(template_name) + .ok_or_else(|| anyhow::anyhow!("Template '{}' not found", template_name))?; + Ok(tmpl.render(ctx)) + } +} + +// ── HTML documentation generator ───────────────────────────────────────────── + +pub struct HtmlDocGenerator { + engine: TemplateEngine, +} + +impl HtmlDocGenerator { + pub fn new() -> Self { + Self { + engine: TemplateEngine::new(), + } + } + + pub fn with_template_dir(mut self, dir: &Path) -> Result { + self.engine.load_from_dir(dir)?; + Ok(self) + } + + pub fn generate_site( + &self, + docs: &ExtractedDocs, + contract_name: &str, + contract_id: &str, + output_dir: &Path, + ) -> Result<()> { + fs::create_dir_all(output_dir)?; + + // Write contract page + let contract_html = self.generate_contract_page(docs, contract_name, contract_id)?; + fs::write(output_dir.join("index.html"), &contract_html)?; + + // Write API reference + let api_html = self.generate_api_reference(docs, contract_name, contract_id)?; + fs::write(output_dir.join("api.html"), &api_html)?; + + // Write assets (inline CSS) + fs::write(output_dir.join("style.css"), STYLESHEET)?; + + Ok(()) + } + + pub fn generate_contract_page( + &self, + docs: &ExtractedDocs, + contract_name: &str, + contract_id: &str, + ) -> Result { + let functions_html = docs + .functions + .iter() + .filter(|f| f.visibility == Visibility::Public) + .map(|f| render_function_card(f)) + .collect::>() + .join("\n"); + + let structs_html = docs + .structs + .iter() + .map(|s| render_struct_card(s)) + .collect::>() + .join("\n"); + + let enums_html = docs + .enums + .iter() + .map(|e| render_enum_card(e)) + .collect::>() + .join("\n"); + + let toc_items: String = docs + .functions + .iter() + .filter(|f| f.visibility == Visibility::Public) + .map(|f| { + format!( + r#"
  • {}
  • "#, + html_id(&f.name), + f.name + ) + }) + .collect::>() + .join("\n"); + + let mut ctx = HashMap::new(); + ctx.insert("title".to_string(), format!("{} — StarForge Docs", contract_name)); + ctx.insert("contract_name".to_string(), contract_name.to_string()); + ctx.insert("contract_id".to_string(), contract_id.to_string()); + ctx.insert("module_doc".to_string(), escape_html(&docs.module_doc)); + ctx.insert("functions_html".to_string(), functions_html); + ctx.insert("structs_html".to_string(), structs_html); + ctx.insert("enums_html".to_string(), enums_html); + ctx.insert("toc_items".to_string(), toc_items); + ctx.insert("source_path".to_string(), docs.source_path.clone()); + + self.engine.render("contract_page", &ctx) + } + + pub fn generate_api_reference( + &self, + docs: &ExtractedDocs, + contract_name: &str, + contract_id: &str, + ) -> Result { + let rows: String = docs + .functions + .iter() + .filter(|f| f.visibility == Visibility::Public) + .map(|f| { + let param_list = f + .params + .iter() + .map(|p| format!("{}: {}", escape_html(&p.name), escape_html(&p.ty))) + .collect::>() + .join(", "); + let ret = f + .return_type + .as_deref() + .map(|r| format!("{}", escape_html(r))) + .unwrap_or_else(|| "()".to_string()); + format!( + r#" + {name} + {params} + {ret} + {desc} +"#, + anchor = html_id(&f.name), + name = escape_html(&f.name), + params = param_list, + ret = ret, + desc = escape_html(f.doc_comment.lines().next().unwrap_or("")), + ) + }) + .collect::>() + .join("\n"); + + let mut ctx = HashMap::new(); + ctx.insert("title".to_string(), format!("{} API Reference — StarForge Docs", contract_name)); + ctx.insert("contract_name".to_string(), contract_name.to_string()); + ctx.insert("contract_id".to_string(), contract_id.to_string()); + ctx.insert("rows".to_string(), rows); + + self.engine.render("api_reference", &ctx) + } + + pub fn generate_multi_contract_index( + &self, + contracts: &[ContractSummary], + output_path: &Path, + ) -> Result<()> { + let cards: String = contracts + .iter() + .map(|c| { + format!( + r#"
    +

    {name}

    +
    {id}
    +

    {desc}

    + {network} +
    "#, + dir = html_id(&c.contract_id), + name = escape_html(&c.name), + id = escape_html(&c.contract_id), + desc = escape_html(&c.description), + network = escape_html(&c.network), + ) + }) + .collect::>() + .join("\n"); + + let mut ctx = HashMap::new(); + ctx.insert("title".to_string(), "StarForge Contract Documentation".to_string()); + ctx.insert("cards".to_string(), cards); + ctx.insert("count".to_string(), contracts.len().to_string()); + + let html = self.engine.render("index", &ctx)?; + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(output_path, html)?; + Ok(()) + } +} + +// ── API reference types ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractSummary { + pub contract_id: String, + pub name: String, + pub description: String, + pub network: String, + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiReference { + pub contract_id: String, + pub contract_name: String, + pub version: String, + pub functions: Vec, + pub events: Vec, + pub generated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiFunction { + pub name: String, + pub description: String, + pub params: Vec, + pub return_type: Option, + pub examples: Vec, + pub visibility: Visibility, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiEvent { + pub name: String, + pub description: String, +} + +pub struct ApiReferenceGenerator; + +impl ApiReferenceGenerator { + pub fn from_extracted( + docs: &ExtractedDocs, + contract_id: &str, + contract_name: &str, + version: &str, + ) -> ApiReference { + let functions = docs + .functions + .iter() + .map(|f| ApiFunction { + name: f.name.clone(), + description: f.doc_comment.clone(), + params: f.params.clone(), + return_type: f.return_type.clone(), + examples: f.examples.clone(), + visibility: f.visibility.clone(), + }) + .collect(); + + // Soroban events are typically enums; look for enums ending in "Event" + let events = docs + .enums + .iter() + .filter(|e| e.name.ends_with("Event") || e.name.ends_with("Events")) + .map(|e| ApiEvent { + name: e.name.clone(), + description: e.doc_comment.clone(), + }) + .collect(); + + ApiReference { + contract_id: contract_id.to_string(), + contract_name: contract_name.to_string(), + version: version.to_string(), + functions, + events, + generated_at: chrono::Utc::now().to_rfc3339(), + } + } + + pub fn save_json(api_ref: &ApiReference, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, serde_json::to_string_pretty(api_ref)?)?; + Ok(()) + } + + pub fn render_markdown(api_ref: &ApiReference) -> String { + let mut md = format!( + "# {} API Reference\n\n**Contract:** `{}` \n**Version:** {} \n**Generated:** {}\n\n", + api_ref.contract_name, + api_ref.contract_id, + api_ref.version, + &api_ref.generated_at[..10] + ); + + md.push_str("## Functions\n\n"); + for f in &api_ref.functions { + md.push_str(&format!("### `{}`\n\n", f.name)); + if !f.description.is_empty() { + md.push_str(&format!("{}\n\n", f.description)); + } + if !f.params.is_empty() { + md.push_str("**Parameters:**\n\n"); + for p in &f.params { + md.push_str(&format!("- `{}`: `{}`\n", p.name, p.ty)); + } + md.push('\n'); + } + if let Some(ref ret) = f.return_type { + md.push_str(&format!("**Returns:** `{}`\n\n", ret)); + } + for example in &f.examples { + md.push_str(&format!("```{}\n{}\n```\n\n", example.lang, example.code)); + } + } + + if !api_ref.events.is_empty() { + md.push_str("## Events\n\n"); + for e in &api_ref.events { + md.push_str(&format!("### `{}`\n\n{}\n\n", e.name, e.description)); + } + } + + md + } +} + +// ── Doc publisher ───────────────────────────────────────────────────────────── + +pub struct DocPublisher; + +impl DocPublisher { + /// Copy a generated docs directory to a destination path. + pub fn publish_to_dir(source: &Path, dest: &Path) -> Result<()> { + fs::create_dir_all(dest)?; + Self::copy_dir_recursive(source, dest)?; + Ok(()) + } + + fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + for entry in fs::read_dir(src)? { + let entry = entry?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + if src_path.is_dir() { + fs::create_dir_all(&dst_path)?; + Self::copy_dir_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) + } + + /// Write a publish manifest file listing all generated files. + pub fn write_manifest(output_dir: &Path, contract_id: &str, version: &str) -> Result { + let mut files = Vec::new(); + for entry in fs::read_dir(output_dir)? { + let entry = entry?; + files.push(entry.file_name().to_string_lossy().to_string()); + } + + let manifest = serde_json::json!({ + "contract_id": contract_id, + "version": version, + "generated_at": chrono::Utc::now().to_rfc3339(), + "files": files, + }); + + let manifest_path = output_dir.join("manifest.json"); + fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?; + Ok(manifest_path) + } + + /// Generate a shell script that can be used to upload docs to a static host. + pub fn generate_deploy_script(output_dir: &Path, endpoint: &str) -> Result { + let script = format!( + "#!/usr/bin/env bash\n\ + # Auto-generated StarForge documentation deploy script\n\ + set -euo pipefail\n\n\ + OUTPUT_DIR=\"{dir}\"\n\ + ENDPOINT=\"{ep}\"\n\n\ + echo \"Deploying docs from $OUTPUT_DIR to $ENDPOINT\"\n\ + rsync -avz --delete \"$OUTPUT_DIR/\" \"$ENDPOINT\"\n\ + echo \"Done.\"\n", + dir = output_dir.display(), + ep = endpoint, + ); + let script_path = output_dir.join("deploy.sh"); + fs::write(&script_path, &script)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?; + } + Ok(script_path) + } +} + +// ── HTML rendering helpers ──────────────────────────────────────────────────── + +fn html_id(s: &str) -> String { + s.chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .to_lowercase() +} + +fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn render_function_card(f: &ExtractedFn) -> String { + let params_html = if f.params.is_empty() { + String::new() + } else { + let rows = f + .params + .iter() + .map(|p| { + format!( + r#"{}{}"#, + escape_html(&p.name), + escape_html(&p.ty) + ) + }) + .collect::>() + .join("\n"); + format!( + r#"

    Parameters

    {rows}
    NameType
    "#, + ) + }; + + let return_html = f + .return_type + .as_deref() + .map(|r| format!(r#"

    Returns

    {}
    "#, escape_html(r))) + .unwrap_or_default(); + + let examples_html = f + .examples + .iter() + .map(|ex| { + format!( + r#"
    {}
    "#, + escape_html(&ex.lang), + escape_html(&ex.code) + ) + }) + .collect::>() + .join("\n"); + + let examples_section = if examples_html.is_empty() { + String::new() + } else { + format!(r#"

    Examples

    {examples_html}
    "#) + }; + + let doc_html = f + .doc_comment + .lines() + .filter(|l| !l.starts_with("```")) + .map(|l| escape_html(l)) + .collect::>() + .join("
    "); + + format!( + r#"
    +
    + {name} + {sig} +
    +
    {doc}
    + {params} + {ret} + {examples} +
    "#, + id = html_id(&f.name), + name = escape_html(&f.name), + sig = escape_html(&f.signature), + doc = doc_html, + params = params_html, + ret = return_html, + examples = examples_section, + ) +} + +fn render_struct_card(s: &ExtractedStruct) -> String { + let fields_html = s + .fields + .iter() + .map(|f| { + format!( + r#"{}{}{}"#, + escape_html(&f.name), + escape_html(&f.ty), + escape_html(&f.doc_comment), + ) + }) + .collect::>() + .join("\n"); + + format!( + r#"
    +
    {name}
    +
    {doc}
    + + {fields}
    FieldTypeDescription
    +
    "#, + name = escape_html(&s.name), + doc = escape_html(&s.doc_comment), + fields = fields_html, + ) +} + +fn render_enum_card(e: &ExtractedEnum) -> String { + let variants_html = e + .variants + .iter() + .map(|v| { + format!( + r#"{}{}"#, + escape_html(&v.name), + escape_html(&v.doc_comment), + ) + }) + .collect::>() + .join("\n"); + + format!( + r#"
    +
    {name}
    +
    {doc}
    + + {variants}
    VariantDescription
    +
    "#, + name = escape_html(&e.name), + doc = escape_html(&e.doc_comment), + variants = variants_html, + ) +} + +// ── Embedded templates ──────────────────────────────────────────────────────── + +const STYLESHEET: &str = r#" +:root { + --bg: #0f1117; + --surface: #1a1d27; + --border: #2a2d3a; + --accent: #7c6af7; + --accent-light: #a89bf7; + --text: #e2e4ed; + --text-muted: #8b8fa8; + --code-bg: #12141f; + --success: #3ecf8e; + --warning: #f6a623; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { background: var(--bg); color: var(--text); font-family: var(--font-sans); line-height: 1.6; } +a { color: var(--accent-light); text-decoration: none; } +a:hover { text-decoration: underline; } +code, pre { font-family: var(--font-mono); } + +/* Layout */ +.layout { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; } +.sidebar { background: var(--surface); border-right: 1px solid var(--border); padding: 2rem 1rem; position: sticky; top: 0; height: 100vh; overflow-y: auto; } +.sidebar h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted); margin-bottom: 0.75rem; margin-top: 1.5rem; } +.sidebar ul { list-style: none; } +.sidebar li { margin: 0.25rem 0; } +.sidebar li a { color: var(--text-muted); font-size: 0.875rem; display: block; padding: 0.25rem 0.5rem; border-radius: 4px; } +.sidebar li a:hover { color: var(--text); background: var(--border); text-decoration: none; } +.main { padding: 2.5rem 3rem; max-width: 960px; } + +/* Header */ +.page-header { border-bottom: 1px solid var(--border); padding-bottom: 1.5rem; margin-bottom: 2rem; } +.page-header h1 { font-size: 2rem; font-weight: 700; } +.page-header .contract-id { font-family: var(--font-mono); color: var(--text-muted); font-size: 0.875rem; margin-top: 0.25rem; } +.page-header .module-doc { color: var(--text-muted); margin-top: 0.75rem; } + +/* Section */ +.section { margin: 2.5rem 0; } +.section h2 { font-size: 1.25rem; color: var(--accent-light); border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; margin-bottom: 1.25rem; } + +/* Function card */ +.fn-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin: 1rem 0; } +.fn-header { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 0.75rem; flex-wrap: wrap; } +.fn-name { font-size: 1.1rem; font-weight: 700; font-family: var(--font-mono); color: var(--accent-light); } +.fn-sig code { font-size: 0.75rem; color: var(--text-muted); } +.fn-doc { color: var(--text-muted); margin-bottom: 1rem; font-size: 0.9rem; } +.params, .returns, .examples { margin-top: 1rem; } +.params h4, .returns h4, .examples h4 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 0.5rem; } + +/* Tables */ +table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } +th { text-align: left; color: var(--text-muted); font-weight: 600; padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); } +td { padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); color: var(--text); } +tr:last-child td { border-bottom: none; } + +/* Code blocks */ +pre { background: var(--code-bg); border: 1px solid var(--border); border-radius: 6px; padding: 1rem; overflow-x: auto; } +pre code { font-size: 0.85rem; color: var(--text); } +code { background: var(--code-bg); padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.875em; } + +/* Cards (index) */ +.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem; } +.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.5rem; transition: border-color 0.2s; } +.card:hover { border-color: var(--accent); } +.card h3 { margin-bottom: 0.5rem; } +.card .contract-id { font-family: var(--font-mono); font-size: 0.75rem; color: var(--text-muted); margin-bottom: 0.75rem; } +.card p { color: var(--text-muted); font-size: 0.875rem; } +.badge { display: inline-block; background: rgba(124,106,247,0.15); color: var(--accent-light); border: 1px solid rgba(124,106,247,0.3); border-radius: 4px; padding: 0.15rem 0.6rem; font-size: 0.75rem; margin-top: 0.75rem; } + +/* Struct / enum cards */ +.struct-card, .enum-card { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 1.25rem; margin: 0.75rem 0; } +.struct-header, .enum-header { margin-bottom: 0.5rem; } +.struct-name, .enum-name { font-family: var(--font-mono); font-weight: 700; color: var(--success); } +.struct-doc, .enum-doc { color: var(--text-muted); font-size: 0.875rem; margin-bottom: 0.75rem; } + +/* Search */ +.search-bar { margin: 1.5rem 0; } +.search-bar input { width: 100%; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; color: var(--text); font-size: 0.95rem; outline: none; } +.search-bar input:focus { border-color: var(--accent); } +"#; + +const BASE_TEMPLATE: &str = r#" + + + + +{{title}} + + + +{{body}} + +"#; + +const CONTRACT_PAGE_TEMPLATE: &str = r#" + + + + +{{title}} + + + +
    + +
    + +
    +

    Functions

    + {{functions_html}} +
    +
    +

    Types

    + {{structs_html}} + {{enums_html}} +
    +
    +
    + +"#; + +const API_REFERENCE_TEMPLATE: &str = r#" + + + + +{{title}} + + + +
    + +
    + +
    + + + {{rows}} +
    FunctionParametersReturnsDescription
    +
    +
    +
    + +"#; + +const INDEX_TEMPLATE: &str = r#" + + + + +{{title}} + + + + + +
    + {{cards}} +
    + + +"#; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + const SAMPLE_SOURCE: &str = r#" +//! Token contract for Stellar Soroban. +//! Implements basic ERC-20-style transfers. + +/// Initialise the contract with an admin address. +/// +/// # Examples +/// ```rust +/// contract.initialize(&env, &admin); +/// ``` +pub fn initialize(env: Env, admin: Address) -> bool { + true +} + +/// Transfer tokens between two accounts. +/// +/// # Examples +/// ```rust +/// contract.transfer(&env, &from, &to, 1000_i128); +/// ``` +pub fn transfer(env: Env, from: Address, to: Address, amount: i128) -> bool { + true +} + +/// Private helper — not exposed in docs. +fn internal_helper(value: u32) {} + +/// Storage keys for the contract. +pub enum StorageKey { + /// Admin address key. + Admin, + /// Balance map key. + Balance, +} + +/// Contract configuration. +pub struct Config { + /// Max tokens in circulation. + pub max_supply: i128, + /// Token name. + pub name: String, +} +"#; + + #[test] + fn extracts_module_doc() { + let docs = DocCommentExtractor::extract_from_source(SAMPLE_SOURCE); + assert!(docs.module_doc.contains("Token contract")); + } + + #[test] + fn extracts_public_functions() { + let docs = DocCommentExtractor::extract_from_source(SAMPLE_SOURCE); + let names: Vec<&str> = docs.functions.iter().map(|f| f.name.as_str()).collect(); + assert!(names.contains(&"initialize")); + assert!(names.contains(&"transfer")); + assert!(names.contains(&"internal_helper")); + } + + #[test] + fn extracts_visibility() { + let docs = DocCommentExtractor::extract_from_source(SAMPLE_SOURCE); + let init = docs.functions.iter().find(|f| f.name == "initialize").unwrap(); + assert_eq!(init.visibility, Visibility::Public); + let helper = docs.functions.iter().find(|f| f.name == "internal_helper").unwrap(); + assert_eq!(helper.visibility, Visibility::Private); + } + + #[test] + fn extracts_params() { + let docs = DocCommentExtractor::extract_from_source(SAMPLE_SOURCE); + let transfer = docs.functions.iter().find(|f| f.name == "transfer").unwrap(); + let param_names: Vec<&str> = transfer.params.iter().map(|p| p.name.as_str()).collect(); + assert!(param_names.contains(&"from")); + assert!(param_names.contains(&"to")); + assert!(param_names.contains(&"amount")); + } + + #[test] + fn extracts_code_examples() { + let docs = DocCommentExtractor::extract_from_source(SAMPLE_SOURCE); + let init = docs.functions.iter().find(|f| f.name == "initialize").unwrap(); + assert_eq!(init.examples.len(), 1); + assert!(init.examples[0].code.contains("initialize")); + } + + #[test] + fn extracts_structs_and_enums() { + let docs = DocCommentExtractor::extract_from_source(SAMPLE_SOURCE); + let struct_names: Vec<&str> = docs.structs.iter().map(|s| s.name.as_str()).collect(); + let enum_names: Vec<&str> = docs.enums.iter().map(|e| e.name.as_str()).collect(); + assert!(struct_names.contains(&"Config")); + assert!(enum_names.contains(&"StorageKey")); + } + + #[test] + fn template_engine_renders() { + let engine = TemplateEngine::new(); + let mut ctx = HashMap::new(); + ctx.insert("title".to_string(), "My Contract".to_string()); + ctx.insert("contract_name".to_string(), "MyContract".to_string()); + ctx.insert("contract_id".to_string(), "CABC123".to_string()); + ctx.insert("module_doc".to_string(), String::new()); + ctx.insert("functions_html".to_string(), String::new()); + ctx.insert("structs_html".to_string(), String::new()); + ctx.insert("enums_html".to_string(), String::new()); + ctx.insert("toc_items".to_string(), String::new()); + ctx.insert("source_path".to_string(), String::new()); + let html = engine.render("contract_page", &ctx).unwrap(); + assert!(html.contains("MyContract")); + assert!(html.contains("CABC123")); + } + + #[test] + fn html_generator_creates_files() { + let docs = DocCommentExtractor::extract_from_source(SAMPLE_SOURCE); + let tmp = tempdir().unwrap(); + let generator = HtmlDocGenerator::new(); + generator + .generate_site(&docs, "TestContract", "CABC123", tmp.path()) + .unwrap(); + assert!(tmp.path().join("index.html").exists()); + assert!(tmp.path().join("api.html").exists()); + assert!(tmp.path().join("style.css").exists()); + } + + #[test] + fn api_reference_generation() { + let docs = DocCommentExtractor::extract_from_source(SAMPLE_SOURCE); + let api = ApiReferenceGenerator::from_extracted(&docs, "CABC123", "TestContract", "1.0.0"); + assert_eq!(api.functions.len(), docs.functions.len()); + let md = ApiReferenceGenerator::render_markdown(&api); + assert!(md.contains("initialize")); + assert!(md.contains("transfer")); + } + + #[test] + fn publisher_copies_dir() { + let docs = DocCommentExtractor::extract_from_source(SAMPLE_SOURCE); + let src_tmp = tempdir().unwrap(); + let dst_tmp = tempdir().unwrap(); + let generator = HtmlDocGenerator::new(); + generator + .generate_site(&docs, "TestContract", "CABC123", src_tmp.path()) + .unwrap(); + DocPublisher::publish_to_dir(src_tmp.path(), dst_tmp.path()).unwrap(); + assert!(dst_tmp.path().join("index.html").exists()); + } + + #[test] + fn publisher_writes_manifest() { + let tmp = tempdir().unwrap(); + fs::write(tmp.path().join("index.html"), "").unwrap(); + let manifest_path = + DocPublisher::write_manifest(tmp.path(), "CABC123", "1.0.0").unwrap(); + assert!(manifest_path.exists()); + let content = fs::read_to_string(&manifest_path).unwrap(); + assert!(content.contains("CABC123")); + } + + #[test] + fn example_extractor_parses_fenced_blocks() { + let comment = "Transfer tokens.\n\n```rust\ncontract.transfer();\n```"; + let examples = ExampleExtractor::extract_from_comment(comment); + assert_eq!(examples.len(), 1); + assert_eq!(examples[0].lang, "rust"); + assert!(examples[0].code.contains("transfer")); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1b469c61..ea451c67 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -18,11 +18,7 @@ pub mod database; pub mod contract_deps; pub mod deploy_history; pub mod deploy_orchestrator; -pub mod doc_api_ref; -pub mod doc_extractor; -pub mod doc_html; -pub mod doc_publisher; -pub mod doc_templates; +pub mod doc_generator; pub mod docs; pub mod governance; pub mod hardware_wallet;