diff --git a/src/commands/docs.rs b/src/commands/docs.rs index 3f4a3ef1..0db5d17b 100644 --- a/src/commands/docs.rs +++ b/src/commands/docs.rs @@ -1,48 +1,82 @@ -use crate::utils::{docs, print as p}; +//! `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 anyhow::Result; use clap::Subcommand; use colored::Colorize; +use std::path::PathBuf; #[derive(Subcommand)] pub enum DocsCommands { - /// Generate documentation for a contract + /// Generate documentation for a contract (metadata-driven) Generate { - /// Contract ID + /// On-chain contract ID contract: String, - /// Contract name + /// Human-friendly contract name #[arg(long)] name: String, - /// Contract description + /// Short description of the contract #[arg(long)] description: String, - /// Network + /// Network (testnet / mainnet) #[arg(long, default_value = "testnet")] network: String, /// Documentation version #[arg(long, default_value = "1.0.0")] version: String, }, - /// Show documentation for a contract + + /// Extract doc comments from a Rust source file or directory + Extract { + /// Path to a `.rs` file or a directory containing `.rs` files + path: PathBuf, + /// Save the extracted data to the docs store under this contract ID + #[arg(long)] + contract: Option, + /// Print a summary table instead of full JSON + #[arg(long)] + summary: bool, + }, + + /// Show stored documentation for a contract Show { /// Contract ID contract: String, - /// Specific version to show (latest if omitted) + /// Specific version (latest if omitted) #[arg(long)] version: Option, }, + /// List all documented contracts List, - /// Search across all documentation + + /// Full-text search across all documentation Search { /// Search query query: String, }, + /// Show documentation versions for a contract Versions { /// Contract ID contract: String, }, - /// Render documentation as Markdown + + /// Export stored documentation as Markdown (printed to stdout) Export { /// Contract ID contract: String, @@ -50,6 +84,64 @@ pub enum DocsCommands { #[arg(long)] version: Option, }, + + /// Generate (or update) the HTML documentation site + Html { + /// Contract ID to render + contract: String, + /// Directory to write HTML files into + #[arg(long, default_value = "docs-site")] + output: PathBuf, + /// Optional custom template directory + #[arg(long)] + templates: Option, + }, + + /// Generate a machine-readable API reference (JSON + Markdown) + #[command(name = "api-ref")] + ApiRef { + /// Contract ID + contract: String, + /// Directory to write reference files into + #[arg(long, default_value = "docs-site")] + output: PathBuf, + /// Only emit JSON (skip Markdown) + #[arg(long)] + json_only: bool, + /// Only emit Markdown (skip JSON) + #[arg(long)] + md_only: bool, + }, + + /// Run the full build + publish pipeline for a contract + 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 + #[arg(long)] + endpoint: Option, + /// Bearer token for HTTP publish + #[arg(long)] + token: Option, + /// Include JSON API reference in the published output + #[arg(long)] + api_json: bool, + /// Include Markdown API reference in the published output + #[arg(long)] + api_md: bool, + }, } pub async fn handle(cmd: DocsCommands) -> Result<()> { @@ -61,14 +153,52 @@ pub async fn handle(cmd: DocsCommands) -> Result<()> { network, version, } => generate(contract, name, description, network, version), + + DocsCommands::Extract { + path, + contract, + summary, + } => extract(path, contract, summary), + DocsCommands::Show { contract, version } => show(contract, version), DocsCommands::List => list(), DocsCommands::Search { query } => search(query), DocsCommands::Versions { contract } => versions(contract), DocsCommands::Export { contract, version } => export(contract, version), + + DocsCommands::Html { + contract, + output, + templates, + } => html(contract, output, templates), + + DocsCommands::ApiRef { + contract, + output, + json_only, + md_only, + } => api_ref(contract, output, json_only, md_only), + + DocsCommands::Publish { + contract, + build_dir, + target, + dest, + repo, + endpoint, + token, + api_json, + api_md, + } => publish( + contract, build_dir, target, dest, repo, endpoint, token, api_json, api_md, + ), } } +// ────────────────────────────────────────────────────────────────────────────── +// generate +// ────────────────────────────────────────────────────────────────────────────── + fn generate( contract: String, name: String, @@ -76,17 +206,17 @@ fn generate( network: String, version: String, ) -> Result<()> { - p::header("Documentation Portal — Generate"); + p::header("Documentation Generator — Generate"); - p::step(1, 3, "Generating documentation structure..."); + p::step(1, 3, "Building documentation structure..."); let functions = vec![ docs::FunctionDoc { name: "initialize".to_string(), - description: "Initialize the contract with admin address".to_string(), + description: "Initialize the contract with an admin address.".to_string(), parameters: vec![docs::ParamDoc { name: "admin".to_string(), ty: "Address".to_string(), - description: "The admin address".to_string(), + description: "The administrator address.".to_string(), required: true, }], returns: Some("bool".to_string()), @@ -94,45 +224,45 @@ fn generate( }, docs::FunctionDoc { name: "transfer".to_string(), - description: "Transfer tokens between accounts".to_string(), + description: "Transfer tokens between accounts.".to_string(), parameters: vec![ docs::ParamDoc { name: "from".to_string(), ty: "Address".to_string(), - description: "Source address".to_string(), + description: "Source address.".to_string(), required: true, }, docs::ParamDoc { name: "to".to_string(), ty: "Address".to_string(), - description: "Destination address".to_string(), + description: "Destination address.".to_string(), required: true, }, docs::ParamDoc { name: "amount".to_string(), ty: "i128".to_string(), - description: "Amount to transfer".to_string(), + description: "Amount of tokens to transfer.".to_string(), required: true, }, ], returns: Some("bool".to_string()), - examples: vec!["contract.transfer(&from, &to, 1000)".to_string()], + examples: vec!["contract.transfer(&from, &to, 1_000)".to_string()], }, ]; let events = vec![docs::EventDoc { name: "Transfer".to_string(), - description: "Emitted on token transfer".to_string(), + description: "Emitted on every successful token transfer.".to_string(), topics: vec![ docs::TopicDoc { name: "from".to_string(), ty: "Address".to_string(), - description: "Source address".to_string(), + description: "Source address.".to_string(), }, docs::TopicDoc { name: "to".to_string(), ty: "Address".to_string(), - description: "Destination address".to_string(), + description: "Destination address.".to_string(), }, ], }]; @@ -141,12 +271,12 @@ fn generate( docs::StorageDoc { key: "admin".to_string(), ty: "Address".to_string(), - description: "Contract administrator address".to_string(), + description: "Contract administrator.".to_string(), }, docs::StorageDoc { key: "balances".to_string(), ty: "Map".to_string(), - description: "Token balances for all accounts".to_string(), + description: "Token balances for all accounts.".to_string(), }, ]; @@ -154,7 +284,7 @@ fn generate( docs::DocSection { title: "Overview".to_string(), content: format!( - "{} is a Soroban smart contract deployed on {}. {}", + "{} is a Soroban smart contract on {}. {}", name, network, description ), order: 0, @@ -162,19 +292,19 @@ fn generate( docs::DocSection { title: "Getting Started".to_string(), content: format!( - "To interact with {}, deploy it to {} and call its functions via the Soroban RPC.", + "Deploy {} to {} and interact via the Soroban RPC.", name, network ), order: 1, }, docs::DocSection { title: "Security".to_string(), - content: "This contract uses address-based authorization. All state-changing operations require the caller to be the authorized address.".to_string(), + content: "All state-changing operations require address-based authorization.".to_string(), order: 2, }, ]; - p::step(2, 3, "Writing documentation files..."); + p::step(2, 3, "Saving documentation..."); let entry = docs::generate_documentation( &contract, &name, @@ -187,17 +317,128 @@ fn generate( sections, )?; - p::step(3, 3, "Updating documentation index..."); + p::step(3, 3, "Updating index..."); println!(); p::success(&format!("Documentation generated for '{}'", name)); p::kv("Contract", &entry.contract_id); p::kv("Version", &entry.version); p::kv("Network", &entry.network); p::kv("Generated", &entry.generated_at[..10]); - p::info("Use `starforge docs show` to view the documentation."); + p::info("Use `starforge docs show ` to view."); + p::info("Use `starforge docs html ` to build HTML."); + p::info("Use `starforge docs api-ref ` for the API reference."); + Ok(()) +} + +// ────────────────────────────────────────────────────────────────────────────── +// extract +// ────────────────────────────────────────────────────────────────────────────── + +fn extract(path: PathBuf, contract: Option, summary: bool) -> Result<()> { + p::header("Documentation Generator — Extract"); + + p::step(1, 2, &format!("Extracting doc comments from {}...", path.display())); + + let extracted: Vec = if path.is_dir() { + doc_extractor::extract_from_directory(&path)? + } else { + vec![doc_extractor::extract_from_file(&path)?] + }; + + let total_fns: usize = extracted.iter().map(|e| e.functions.len()).sum(); + let total_structs: usize = extracted.iter().map(|e| e.structs.len()).sum(); + let total_enums: usize = extracted.iter().map(|e| e.enums.len()).sum(); + let total_examples: usize = extracted.iter().map(|e| e.examples.len()).sum(); + + p::step(2, 2, "Extraction complete."); + println!(); + p::kv("Files processed", &extracted.len().to_string()); + p::kv("Functions found", &total_fns.to_string()); + p::kv("Structs found", &total_structs.to_string()); + p::kv("Enums found", &total_enums.to_string()); + p::kv("Code examples", &total_examples.to_string()); + + if summary { + // Print summary table. + println!(); + for doc in &extracted { + println!( + " {} {}", + "→".cyan(), + doc.source_file.display().to_string().bright_white() + ); + if let Some(ref md) = doc.module_doc { + let first_line = md.lines().next().unwrap_or(""); + println!(" {}", first_line.dimmed()); + } + for func in &doc.functions { + println!(" {} fn {}", "•".dimmed(), func.name.cyan()); + } + } + } else { + // Full JSON output. + let json = serde_json::to_string_pretty(&extracted)?; + println!("\n{}", json); + } + + // Optionally persist into the docs store. + if let Some(contract_id) = contract { + p::info(&format!("Saving extracted docs under contract '{}'...", contract_id)); + + // Build FunctionDoc list from extracted data. + let functions: Vec = extracted + .iter() + .flat_map(|e| { + e.functions.iter().map(|f| docs::FunctionDoc { + name: f.name.clone(), + description: f.doc.lines().next().unwrap_or("").to_string(), + parameters: f + .params + .iter() + .map(|p| docs::ParamDoc { + name: p.name.clone(), + ty: p.ty.clone(), + description: String::new(), + required: true, + }) + .collect(), + returns: f.return_type.clone(), + examples: f.examples.clone(), + }) + }) + .collect(); + + let module_desc = extracted + .first() + .and_then(|e| e.module_doc.as_deref()) + .unwrap_or("") + .lines() + .next() + .unwrap_or("") + .to_string(); + + docs::generate_documentation( + &contract_id, + &contract_id, + &module_desc, + "testnet", + "1.0.0", + functions, + vec![], + vec![], + vec![], + )?; + + p::success("Extracted documentation saved to docs store."); + } + Ok(()) } +// ────────────────────────────────────────────────────────────────────────────── +// show +// ────────────────────────────────────────────────────────────────────────────── + fn show(contract: String, version: Option) -> Result<()> { p::header("Documentation Portal — View"); @@ -210,8 +451,8 @@ fn show(contract: String, version: Option) -> Result<()> { p::kv("Network", &entry.network); p::kv("Generated", &entry.generated_at[..10]); p::separator(); - println!(); + for section in &entry.sections { println!(" {} {}", "##".dimmed(), section.title.bright_white()); println!(" {}", section.content.dimmed()); @@ -223,21 +464,15 @@ fn show(contract: String, version: Option) -> Result<()> { for func in &entry.api.functions { println!(" {} `{}`", "→".cyan(), func.name.bright_white()); println!(" {}", func.description); - if !func.parameters.is_empty() { - for param in &func.parameters { - let req = if param.required { - "required" - } else { - "optional" - }; - println!( - " • {} ({}): {} [{}]", - param.name, param.ty, param.description, req - ); - } + for param in &func.parameters { + let req = if param.required { "required" } else { "optional" }; + println!( + " • {} ({}): {} [{}]", + param.name, param.ty, param.description, req + ); } - if let Some(ref returns) = func.returns { - println!(" Returns: {}", returns); + if let Some(ref ret) = func.returns { + println!(" Returns: {}", ret); } println!(); } @@ -257,11 +492,8 @@ fn show(contract: String, version: Option) -> Result<()> { if !entry.api.storage.is_empty() { p::info("Storage Layout"); - for storage in &entry.api.storage { - println!( - " • {} ({}): {}", - storage.key, storage.ty, storage.description - ); + for s in &entry.api.storage { + println!(" • {} ({}): {}", s.key, s.ty, s.description); } } @@ -270,6 +502,10 @@ fn show(contract: String, version: Option) -> Result<()> { Ok(()) } +// ────────────────────────────────────────────────────────────────────────────── +// list +// ────────────────────────────────────────────────────────────────────────────── + fn list() -> Result<()> { p::header("Documentation Portal — Index"); @@ -298,13 +534,17 @@ fn list() -> Result<()> { Ok(()) } +// ────────────────────────────────────────────────────────────────────────────── +// search +// ────────────────────────────────────────────────────────────────────────────── + fn search(query: String) -> Result<()> { p::header(&format!("Documentation Search: '{}'", query)); let results = docs::search_documentation(&query)?; if results.is_empty() { - p::info("No documentation matched your search query."); + p::info("No documentation matched your query."); return Ok(()); } @@ -329,6 +569,10 @@ fn search(query: String) -> Result<()> { Ok(()) } +// ────────────────────────────────────────────────────────────────────────────── +// versions +// ────────────────────────────────────────────────────────────────────────────── + fn versions(contract: String) -> Result<()> { p::header("Documentation Portal — Versions"); p::kv("Contract", &contract); @@ -336,13 +580,13 @@ fn versions(contract: String) -> Result<()> { let versions = docs::list_versions(&contract)?; if versions.is_empty() { - p::info("No documentation versions found for this contract."); + p::info("No documentation versions found."); return Ok(()); } println!(); - for version in &versions { - println!(" {} v{}", "→".cyan(), version.bright_white()); + for v in &versions { + println!(" {} v{}", "→".cyan(), v.bright_white()); } println!(); @@ -350,11 +594,143 @@ fn versions(contract: String) -> Result<()> { Ok(()) } +// ────────────────────────────────────────────────────────────────────────────── +// export +// ────────────────────────────────────────────────────────────────────────────── + fn export(contract: String, version: Option) -> Result<()> { p::header("Documentation Portal — Export Markdown"); - let md = docs::render_markdown(&contract, version.as_deref())?; println!("{}", md); + Ok(()) +} + +// ────────────────────────────────────────────────────────────────────────────── +// html +// ────────────────────────────────────────────────────────────────────────────── + +fn html(contract: String, output: PathBuf, templates: Option) -> Result<()> { + p::header("Documentation Generator — HTML Site"); + + p::step(1, 3, "Loading documentation..."); + let entry = docs::get_documentation(&contract, None)?; + + p::step(2, 3, &format!("Rendering HTML to {}...", output.display())); + let page_path = + doc_html::generate_html_site(&entry, &output, templates.as_deref())?; + + p::step(3, 3, "HTML site ready."); + println!(); + p::success("HTML documentation site generated."); + p::kv("Contract page", &page_path.display().to_string()); + p::kv("Portal index", &output.join("index.html").display().to_string()); + p::info("Open index.html in a browser to view the portal."); + Ok(()) +} + +// ────────────────────────────────────────────────────────────────────────────── +// api_ref +// ────────────────────────────────────────────────────────────────────────────── + +fn api_ref(contract: String, output: PathBuf, json_only: bool, md_only: bool) -> Result<()> { + p::header("Documentation Generator — API Reference"); + + p::step(1, 3, "Loading documentation..."); + let entry = docs::get_documentation(&contract, None)?; + + p::step(2, 3, "Building API reference..."); + let api_reference = doc_api_ref::build_api_reference(&entry); + + p::step(3, 3, &format!("Writing to {}...", output.display())); + + let emit_json = !md_only; + let emit_md = !json_only; + + if emit_json { + doc_api_ref::write_json(&api_reference, &output)?; + let path = output.join(format!( + "{}_api.json", + contract.replace('/', "_") + )); + p::kv("JSON ref", &path.display().to_string()); + } + if emit_md { + doc_api_ref::write_markdown(&api_reference, &output)?; + let path = output.join(format!( + "{}_api.md", + contract.replace('/', "_") + )); + p::kv("Markdown ref", &path.display().to_string()); + } + + println!(); + p::success("API reference generated."); + p::kv("Functions", &api_reference.functions.len().to_string()); + p::kv("Events", &api_reference.events.len().to_string()); + p::kv("Storage keys", &api_reference.storage.len().to_string()); + Ok(()) +} + +// ────────────────────────────────────────────────────────────────────────────── +// publish +// ────────────────────────────────────────────────────────────────────────────── +fn publish( + contract: String, + build_dir: PathBuf, + target: String, + dest: Option, + repo: Option, + endpoint: Option, + token: Option, + api_json: bool, + api_md: bool, +) -> Result<()> { + p::header("Documentation Generator — Publish Pipeline"); + + p::step(1, 4, "Loading documentation..."); + let entry = docs::get_documentation(&contract, None)?; + + let publish_target = match target.as_str() { + "github-pages" | "gh-pages" => { + let repo_path = repo.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + doc_publisher::PublishTarget::GitHubPages { + repo_path, + commit_message: format!( + "docs: publish {} v{}", + entry.name, entry.version + ), + } + } + url if url.starts_with("http") => doc_publisher::PublishTarget::CustomHttp { + endpoint: url.to_string(), + auth_token: token, + }, + _ => { + let dest_path = dest.unwrap_or_else(|| PathBuf::from("docs-output")); + doc_publisher::PublishTarget::Local { dest: dest_path } + } + }; + + p::step(2, 4, "Configuring publish options..."); + let options = doc_publisher::PublishOptions { + build_dir, + target: publish_target, + include_api_json: api_json, + include_api_markdown: api_md, + custom_template_dir: None, + }; + + p::step(3, 4, "Running build + publish pipeline..."); + let result = doc_publisher::publish(&entry, &options)?; + + p::step(4, 4, "Recording publish event..."); + let _ = doc_publisher::record_publish(&entry, &result); + + println!(); + p::success("Documentation published successfully."); + p::kv("Published to", &result.published_to); + p::kv("Files written", &result.files_written.to_string()); + p::info(&result.message); Ok(()) } diff --git a/src/utils/doc_api_ref.rs b/src/utils/doc_api_ref.rs new file mode 100644 index 00000000..7a71c5bb --- /dev/null +++ b/src/utils/doc_api_ref.rs @@ -0,0 +1,373 @@ +//! API reference generator for Soroban contract documentation. +//! +//! Produces a machine-readable [`ApiReference`] JSON blob and a human-friendly +//! Markdown reference from a [`crate::utils::docs::DocEntry`]. The Markdown +//! format is compatible with GitHub, GitLab, and most static-site generators. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +use crate::utils::docs::{DocEntry, EventDoc, FunctionDoc, StorageDoc}; + +// ────────────────────────────────────────────────────────────────────────────── +// Public data types +// ────────────────────────────────────────────────────────────────────────────── + +/// Structured, machine-readable API reference for one contract version. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiReference { + pub contract_id: String, + pub name: String, + pub version: String, + pub network: String, + pub functions: Vec, + pub events: Vec, + pub storage: Vec, + pub generated_at: String, +} + +/// API reference entry for a single function. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiFunctionRef { + pub name: String, + pub description: String, + pub signature: String, + pub parameters: Vec, + pub returns: Option, + pub examples: Vec, + pub is_mutating: bool, +} + +/// A parameter in an [`ApiFunctionRef`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiParamRef { + pub name: String, + pub ty: String, + pub description: String, + pub required: bool, +} + +/// API reference entry for a contract event. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiEventRef { + pub name: String, + pub description: String, + pub topics: Vec, +} + +/// A topic field in an [`ApiEventRef`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiTopicRef { + pub name: String, + pub ty: String, + pub description: String, +} + +/// API reference entry for a storage slot. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiStorageRef { + pub key: String, + pub ty: String, + pub description: String, +} + +// ────────────────────────────────────────────────────────────────────────────── +// Builder +// ────────────────────────────────────────────────────────────────────────────── + +/// Build an [`ApiReference`] from a [`DocEntry`]. +pub fn build_api_reference(entry: &DocEntry) -> ApiReference { + ApiReference { + contract_id: entry.contract_id.clone(), + name: entry.name.clone(), + version: entry.version.clone(), + network: entry.network.clone(), + functions: entry + .api + .functions + .iter() + .map(build_function_ref) + .collect(), + events: entry.api.events.iter().map(build_event_ref).collect(), + storage: entry.api.storage.iter().map(build_storage_ref).collect(), + generated_at: entry.generated_at.clone(), + } +} + +fn build_function_ref(func: &FunctionDoc) -> ApiFunctionRef { + let param_sig: Vec = func + .parameters + .iter() + .map(|p| format!("{}: {}", p.name, p.ty)) + .collect(); + + let returns_str = func + .returns + .as_deref() + .unwrap_or("()"); + + let signature = format!( + "fn {}({}) -> {}", + func.name, + param_sig.join(", "), + returns_str + ); + + // Heuristic: functions whose name starts with common mutation verbs are + // considered state-mutating. + let mutation_prefixes = ["set_", "transfer", "mint", "burn", "create", "init", "update", "delete", "add", "remove", "approve"]; + let is_mutating = mutation_prefixes + .iter() + .any(|p| func.name.starts_with(p) || func.name == p.trim_end_matches('_')); + + ApiFunctionRef { + name: func.name.clone(), + description: func.description.clone(), + signature, + parameters: func + .parameters + .iter() + .map(|p| ApiParamRef { + name: p.name.clone(), + ty: p.ty.clone(), + description: p.description.clone(), + required: p.required, + }) + .collect(), + returns: func.returns.clone(), + examples: func.examples.clone(), + is_mutating, + } +} + +fn build_event_ref(event: &EventDoc) -> ApiEventRef { + ApiEventRef { + name: event.name.clone(), + description: event.description.clone(), + topics: event + .topics + .iter() + .map(|t| ApiTopicRef { + name: t.name.clone(), + ty: t.ty.clone(), + description: t.description.clone(), + }) + .collect(), + } +} + +fn build_storage_ref(storage: &StorageDoc) -> ApiStorageRef { + ApiStorageRef { + key: storage.key.clone(), + ty: storage.ty.clone(), + description: storage.description.clone(), + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Serialisers +// ────────────────────────────────────────────────────────────────────────────── + +/// Render `reference` as a pretty-printed JSON string. +pub fn to_json(reference: &ApiReference) -> Result { + Ok(serde_json::to_string_pretty(reference)?) +} + +/// Render `reference` as a Markdown API reference document. +pub fn to_markdown(reference: &ApiReference) -> String { + let mut md = String::new(); + + md.push_str(&format!("# {} — API Reference\n\n", reference.name)); + md.push_str(&format!( + "| | |\n|---|---|\n| **Contract ID** | `{}` |\n| **Version** | `{}` |\n| **Network** | {} |\n| **Generated** | {} |\n\n", + reference.contract_id, reference.version, reference.network, &reference.generated_at[..10] + )); + + // Functions + if !reference.functions.is_empty() { + md.push_str("## Functions\n\n"); + for func in &reference.functions { + md.push_str(&format!("### `{}`\n\n", func.name)); + if !func.description.is_empty() { + md.push_str(&format!("{}\n\n", func.description)); + } + md.push_str(&format!("```rust\n{}\n```\n\n", func.signature)); + + if func.is_mutating { + md.push_str("> ⚠️ **State-mutating** — this function modifies contract storage.\n\n"); + } + + if !func.parameters.is_empty() { + md.push_str("**Parameters:**\n\n"); + md.push_str("| Name | Type | Required | Description |\n"); + md.push_str("|------|------|----------|-------------|\n"); + for p in &func.parameters { + md.push_str(&format!( + "| `{}` | `{}` | {} | {} |\n", + p.name, + p.ty, + if p.required { "✓" } else { "optional" }, + p.description + )); + } + md.push('\n'); + } + + if let Some(ref ret) = func.returns { + md.push_str(&format!("**Returns:** `{}`\n\n", ret)); + } + + if !func.examples.is_empty() { + md.push_str("**Examples:**\n\n"); + for ex in &func.examples { + md.push_str(&format!("```rust\n{}\n```\n\n", ex)); + } + } + } + } + + // Events + if !reference.events.is_empty() { + md.push_str("## Events\n\n"); + for event in &reference.events { + md.push_str(&format!("### `{}`\n\n", event.name)); + if !event.description.is_empty() { + md.push_str(&format!("{}\n\n", event.description)); + } + if !event.topics.is_empty() { + md.push_str("| Topic | Type | Description |\n|-------|------|-------------|\n"); + for t in &event.topics { + md.push_str(&format!( + "| `{}` | `{}` | {} |\n", + t.name, t.ty, t.description + )); + } + md.push('\n'); + } + } + } + + // Storage + if !reference.storage.is_empty() { + md.push_str("## Storage\n\n"); + md.push_str("| Key | Type | Description |\n|-----|------|-------------|\n"); + for s in &reference.storage { + md.push_str(&format!( + "| `{}` | `{}` | {} |\n", + s.key, s.ty, s.description + )); + } + md.push('\n'); + } + + md.push_str("---\n*Generated by StarForge Contract Documentation Generator*\n"); + md +} + +/// Write the API reference JSON to `/_api.json`. +pub fn write_json(reference: &ApiReference, output_dir: &Path) -> Result<()> { + fs::create_dir_all(output_dir)?; + let safe_id = reference.contract_id.replace('/', "_"); + let path = output_dir.join(format!("{}_api.json", safe_id)); + fs::write(path, to_json(reference)?)?; + Ok(()) +} + +/// Write the Markdown API reference to `/_api.md`. +pub fn write_markdown(reference: &ApiReference, output_dir: &Path) -> Result<()> { + fs::create_dir_all(output_dir)?; + let safe_id = reference.contract_id.replace('/', "_"); + let path = output_dir.join(format!("{}_api.md", safe_id)); + fs::write(path, to_markdown(reference))?; + Ok(()) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::docs::{ApiDocumentation, DocEntry, DocSection, FunctionDoc, ParamDoc}; + + fn sample_entry() -> DocEntry { + DocEntry { + contract_id: "CABC1234".to_string(), + name: "TokenContract".to_string(), + description: "Sample token contract".to_string(), + version: "1.0.0".to_string(), + network: "testnet".to_string(), + generated_at: "2026-01-01T00:00:00Z".to_string(), + sections: vec![DocSection { + title: "Overview".to_string(), + content: "Overview text.".to_string(), + order: 0, + }], + api: ApiDocumentation { + functions: vec![FunctionDoc { + name: "transfer".to_string(), + description: "Transfer tokens".to_string(), + parameters: vec![ + ParamDoc { + name: "from".to_string(), + ty: "Address".to_string(), + description: "Source address".to_string(), + required: true, + }, + ParamDoc { + name: "amount".to_string(), + ty: "i128".to_string(), + description: "Amount".to_string(), + required: true, + }, + ], + returns: Some("bool".to_string()), + examples: vec!["contract.transfer(&from, 100)".to_string()], + }], + events: vec![], + storage: vec![], + }, + } + } + + #[test] + fn builds_api_reference() { + let entry = sample_entry(); + let api_ref = build_api_reference(&entry); + assert_eq!(api_ref.functions.len(), 1); + assert!(api_ref.functions[0].signature.contains("fn transfer")); + assert!(api_ref.functions[0].is_mutating); + } + + #[test] + fn markdown_contains_function_name() { + let entry = sample_entry(); + let api_ref = build_api_reference(&entry); + let md = to_markdown(&api_ref); + assert!(md.contains("### `transfer`")); + assert!(md.contains("State-mutating")); + } + + #[test] + fn json_is_valid() { + let entry = sample_entry(); + let api_ref = build_api_reference(&entry); + let json = to_json(&api_ref).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["name"], "TokenContract"); + } + + #[test] + fn write_files_to_disk() { + let dir = tempfile::tempdir().unwrap(); + let entry = sample_entry(); + let api_ref = build_api_reference(&entry); + write_json(&api_ref, dir.path()).unwrap(); + write_markdown(&api_ref, dir.path()).unwrap(); + assert!(dir.path().join("CABC1234_api.json").exists()); + assert!(dir.path().join("CABC1234_api.md").exists()); + } +} diff --git a/src/utils/doc_extractor.rs b/src/utils/doc_extractor.rs new file mode 100644 index 00000000..abb523c2 --- /dev/null +++ b/src/utils/doc_extractor.rs @@ -0,0 +1,623 @@ +//! Doc comment extraction for Soroban/Rust contract source files. +//! +//! Parses `///` and `//!` doc comments, function signatures, struct/enum +//! definitions, and inline `# Example` blocks from `.rs` source files, +//! producing structured [`ExtractedDoc`] values that feed the rest of the +//! documentation pipeline. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +// ────────────────────────────────────────────────────────────────────────────── +// Public data types +// ────────────────────────────────────────────────────────────────────────────── + +/// A single extracted doc comment block together with the item it documents. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedDoc { + /// Module-level (`//!`) doc comment, if any. + pub module_doc: Option, + /// All public functions found in the file. + pub functions: Vec, + /// All public structs found in the file. + pub structs: Vec, + /// All public enums found in the file. + pub enums: Vec, + /// Freestanding code examples found inside `# Examples` blocks. + pub examples: Vec, + /// Source file this was extracted from. + pub source_file: PathBuf, +} + +/// An extracted `pub fn` with its doc comment and parameter list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedFunction { + /// Function name. + pub name: String, + /// Full doc comment (leading `/// ` stripped, joined with newlines). + pub doc: String, + /// Parameter names and types parsed from the signature. + pub params: Vec, + /// Return type string, if present. + pub return_type: Option, + /// Code blocks found inside `# Examples` sections of the doc comment. + pub examples: Vec, + /// `true` when the function has `#[contractimpl]` / `pub` visibility. + pub is_public: bool, +} + +/// A parameter extracted from a function signature. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedParam { + pub name: String, + pub ty: String, +} + +/// An extracted `pub struct` with its doc comment and field list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedStruct { + pub name: String, + pub doc: String, + pub fields: Vec, +} + +/// A field inside a struct. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedField { + pub name: String, + pub ty: String, + pub doc: String, +} + +/// An extracted `pub enum` with its doc comment and variants. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedEnum { + pub name: String, + pub doc: String, + pub variants: Vec, +} + +/// A variant inside an enum. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedVariant { + pub name: String, + pub doc: String, +} + +/// A freestanding code example extracted from a doc comment. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtractedExample { + /// Parent function / type the example belongs to (empty = module-level). + pub parent: String, + /// Raw code text (without the triple-backtick fences). + pub code: String, + /// Language hint from the opening fence (e.g. `rust`, `bash`). + pub language: String, +} + +// ────────────────────────────────────────────────────────────────────────────── +// Entry points +// ────────────────────────────────────────────────────────────────────────────── + +/// Extract documentation from a single `.rs` source file. +pub fn extract_from_file(path: &Path) -> Result { + let source = fs::read_to_string(path) + .with_context(|| format!("Failed to read source file: {}", path.display()))?; + Ok(extract_from_source(&source, path.to_path_buf())) +} + +/// Extract documentation from all `.rs` files found recursively under `dir`. +pub fn extract_from_directory(dir: &Path) -> Result> { + let mut results = Vec::new(); + collect_rs_files(dir, &mut results)?; + Ok(results) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Core parser +// ────────────────────────────────────────────────────────────────────────────── + +/// Parse `source` and return an [`ExtractedDoc`]. +pub fn extract_from_source(source: &str, source_file: PathBuf) -> ExtractedDoc { + let lines: Vec<&str> = source.lines().collect(); + let mut doc = ExtractedDoc { + module_doc: None, + functions: Vec::new(), + structs: Vec::new(), + enums: Vec::new(), + examples: Vec::new(), + source_file, + }; + + // Gather module-level `//!` comments from the top of the file. + let mut module_lines: Vec = Vec::new(); + for line in &lines { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("//!") { + module_lines.push(rest.trim_start().to_string()); + } else if trimmed.starts_with("//") || trimmed.is_empty() { + // skip ordinary comments and blank lines at the top + continue; + } else { + break; + } + } + if !module_lines.is_empty() { + doc.module_doc = Some(module_lines.join("\n")); + } + + // Walk lines, collecting `///` comment blocks then the item that follows. + let mut i = 0usize; + let mut pending_doc: Vec = Vec::new(); + + while i < lines.len() { + let trimmed = lines[i].trim(); + + if let Some(rest) = trimmed.strip_prefix("///") { + // Accumulate doc comment lines. + pending_doc.push(rest.trim_start().to_string()); + i += 1; + continue; + } + + // Skip attributes and blank lines between doc comment and item. + if trimmed.starts_with('#') || trimmed.is_empty() { + i += 1; + continue; + } + + // Try to match a `pub fn`, `pub struct`, or `pub enum` declaration. + if let Some(func) = try_parse_function(&lines, i, &pending_doc) { + let examples = extract_code_examples(&func.doc, &func.name); + doc.examples.extend(examples); + doc.functions.push(func); + pending_doc.clear(); + } else if let Some(st) = try_parse_struct(&lines, i, &pending_doc) { + doc.structs.push(st); + pending_doc.clear(); + } else if let Some(en) = try_parse_enum(&lines, i, &pending_doc) { + doc.enums.push(en); + pending_doc.clear(); + } else { + // Not something we recognise — discard accumulated doc. + if !pending_doc.is_empty() { + pending_doc.clear(); + } + } + + i += 1; + } + + doc +} + +// ────────────────────────────────────────────────────────────────────────────── +// Item parsers +// ────────────────────────────────────────────────────────────────────────────── + +fn try_parse_function( + lines: &[&str], + idx: usize, + doc_lines: &[String], +) -> Option { + let line = lines[idx].trim(); + + // Match lines like: `pub fn foo(`, `pub async fn foo(`, `fn foo(` + let is_pub = line.starts_with("pub "); + let fn_pos = line.find("fn ")?; + let after_fn = &line[fn_pos + 3..]; + + let name_end = after_fn.find(|c: char| !c.is_alphanumeric() && c != '_')?; + let name = after_fn[..name_end].to_string(); + if name.is_empty() { + return None; + } + + // Collect the full signature (may span several lines until we find `{` or `;`). + let mut sig = String::new(); + let mut j = idx; + loop { + sig.push_str(lines[j].trim()); + sig.push(' '); + if sig.contains('{') || sig.contains(';') { + break; + } + j += 1; + if j >= lines.len() { + break; + } + } + + let params = parse_params(&sig); + let return_type = parse_return_type(&sig); + let doc_text = doc_lines.join("\n"); + let examples = extract_code_examples(&doc_text, &name); + + Some(ExtractedFunction { + name, + doc: doc_text, + params, + return_type, + examples: examples.iter().map(|e| e.code.clone()).collect(), + is_public: is_pub, + }) +} + +fn try_parse_struct( + lines: &[&str], + idx: usize, + doc_lines: &[String], +) -> Option { + let line = lines[idx].trim(); + if !line.contains("struct ") { + return None; + } + + let name = extract_item_name(line, "struct ")?; + let fields = collect_struct_fields(lines, idx); + + Some(ExtractedStruct { + name, + doc: doc_lines.join("\n"), + fields, + }) +} + +fn try_parse_enum( + lines: &[&str], + idx: usize, + doc_lines: &[String], +) -> Option { + let line = lines[idx].trim(); + if !line.contains("enum ") { + return None; + } + + let name = extract_item_name(line, "enum ")?; + let variants = collect_enum_variants(lines, idx); + + Some(ExtractedEnum { + name, + doc: doc_lines.join("\n"), + variants, + }) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Signature helpers +// ────────────────────────────────────────────────────────────────────────────── + +fn parse_params(sig: &str) -> Vec { + // Extract the content between the outermost `(` … `)`. + let open = sig.find('(')?; + let close = sig.rfind(')')?; + if open >= close { + return vec![]; + } + let inner = &sig[open + 1..close]; + + let mut params = Vec::new(); + for raw in split_params(inner) { + let raw = raw.trim(); + if raw.is_empty() || raw == "self" || raw == "&self" || raw == "&mut self" { + continue; + } + // `name: Type` or just `Type` + if let Some(colon) = raw.find(':') { + let name = raw[..colon].trim().trim_start_matches('_').to_string(); + let ty = raw[colon + 1..].trim().to_string(); + if !name.is_empty() && !ty.is_empty() { + params.push(ExtractedParam { name, ty }); + } + } + } + params +} + +fn parse_return_type(sig: &str) -> Option { + // Look for `->` after the closing `)`. + let close = sig.rfind(')')?; + let after = &sig[close + 1..]; + let arrow = after.find("->")?; + let ret = after[arrow + 2..].trim(); + // Strip trailing `{` or `;`. + let ret = ret + .trim_end_matches('{') + .trim_end_matches(';') + .trim() + .to_string(); + if ret.is_empty() { + None + } else { + Some(ret) + } +} + +/// Split a parameter list by commas, respecting angle-bracket nesting. +fn split_params(s: &str) -> Vec { + let mut parts = Vec::new(); + let mut depth = 0i32; + let mut current = String::new(); + for c in s.chars() { + match c { + '<' => { + depth += 1; + current.push(c); + } + '>' => { + depth -= 1; + current.push(c); + } + ',' if depth == 0 => { + parts.push(current.trim().to_string()); + current = String::new(); + } + _ => current.push(c), + } + } + if !current.trim().is_empty() { + parts.push(current.trim().to_string()); + } + parts +} + +fn extract_item_name(line: &str, keyword: &str) -> Option { + let pos = line.find(keyword)?; + let rest = &line[pos + keyword.len()..]; + let end = rest + .find(|c: char| !c.is_alphanumeric() && c != '_') + .unwrap_or(rest.len()); + let name = rest[..end].to_string(); + if name.is_empty() { + None + } else { + Some(name) + } +} + +fn collect_struct_fields(lines: &[&str], start: usize) -> Vec { + let mut fields = Vec::new(); + let mut in_body = false; + let mut pending_doc: Vec = Vec::new(); + + for line in &lines[start..] { + let trimmed = line.trim(); + if trimmed.contains('{') { + in_body = true; + continue; + } + if trimmed == "}" { + break; + } + if !in_body { + continue; + } + + if let Some(rest) = trimmed.strip_prefix("///") { + pending_doc.push(rest.trim_start().to_string()); + continue; + } + + if trimmed.starts_with("pub ") || (!trimmed.starts_with("//") && trimmed.contains(':')) { + // `pub name: Type,` or `name: Type,` + let clean = trimmed + .trim_start_matches("pub ") + .trim_end_matches(',') + .trim(); + if let Some(colon) = clean.find(':') { + let name = clean[..colon].trim().to_string(); + let ty = clean[colon + 1..].trim().to_string(); + if !name.is_empty() && !ty.is_empty() && !name.starts_with("//") { + fields.push(ExtractedField { + name, + ty, + doc: pending_doc.join("\n"), + }); + } + } + pending_doc.clear(); + } else { + pending_doc.clear(); + } + } + + fields +} + +fn collect_enum_variants(lines: &[&str], start: usize) -> Vec { + let mut variants = Vec::new(); + let mut in_body = false; + let mut pending_doc: Vec = Vec::new(); + + for line in &lines[start..] { + let trimmed = line.trim(); + if trimmed.contains('{') { + in_body = true; + continue; + } + if trimmed == "}" { + break; + } + if !in_body { + continue; + } + + if let Some(rest) = trimmed.strip_prefix("///") { + pending_doc.push(rest.trim_start().to_string()); + continue; + } + + if trimmed.starts_with("//") || trimmed.is_empty() { + continue; + } + + // Variant name, possibly followed by `{..}`, `(..)`, or `,` + let name_end = trimmed + .find(|c: char| !c.is_alphanumeric() && c != '_') + .unwrap_or(trimmed.len()); + let name = trimmed[..name_end].to_string(); + if !name.is_empty() { + variants.push(ExtractedVariant { + name, + doc: pending_doc.join("\n"), + }); + } + pending_doc.clear(); + } + + variants +} + +// ────────────────────────────────────────────────────────────────────────────── +// Example extraction +// ────────────────────────────────────────────────────────────────────────────── + +/// Extract fenced code blocks from a doc comment string. +pub fn extract_code_examples(doc: &str, parent: &str) -> Vec { + let mut examples = Vec::new(); + let mut in_block = false; + let mut language = String::new(); + let mut current: Vec = Vec::new(); + + for line in doc.lines() { + if !in_block { + if line.trim_start().starts_with("```") { + in_block = true; + language = line.trim_start().trim_start_matches('`').trim().to_string(); + if language.is_empty() { + language = "rust".to_string(); + } + current.clear(); + } + } else if line.trim_start().starts_with("```") { + examples.push(ExtractedExample { + parent: parent.to_string(), + code: current.join("\n"), + language: language.clone(), + }); + in_block = false; + current.clear(); + } else { + current.push(line.to_string()); + } + } + + examples +} + +// ────────────────────────────────────────────────────────────────────────────── +// Directory walker +// ────────────────────────────────────────────────────────────────────────────── + +fn collect_rs_files(dir: &Path, results: &mut Vec) -> Result<()> { + if !dir.is_dir() { + return Ok(()); + } + for entry in fs::read_dir(dir) + .with_context(|| format!("Cannot read directory: {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_rs_files(&path, results)?; + } else if path.extension().and_then(|e| e.to_str()) == Some("rs") { + match extract_from_file(&path) { + Ok(doc) => results.push(doc), + Err(e) => eprintln!(" Warning: skipping {}: {}", path.display(), e), + } + } + } + Ok(()) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + const SAMPLE: &str = r#" +//! Module-level documentation for a sample contract. + +use soroban_sdk::{contract, contractimpl, Address, Env}; + +/// Transfer tokens from one account to another. +/// +/// # Examples +/// ```rust +/// contract.transfer(&env, &from, &to, 100); +/// ``` +pub fn transfer(env: Env, from: Address, to: Address, amount: i128) -> bool { + true +} + +/// The contract configuration. +pub struct Config { + /// Administrator address. + pub admin: Address, + /// Maximum supply. + pub max_supply: i128, +} + +/// Error variants for this contract. +pub enum ContractError { + /// Caller is not the admin. + Unauthorized, + /// Requested amount exceeds balance. + InsufficientBalance, +} +"#; + + #[test] + fn extracts_module_doc() { + let doc = extract_from_source(SAMPLE, PathBuf::from("test.rs")); + assert!(doc.module_doc.is_some()); + assert!(doc + .module_doc + .unwrap() + .contains("Module-level documentation")); + } + + #[test] + fn extracts_function() { + let doc = extract_from_source(SAMPLE, PathBuf::from("test.rs")); + let func = doc.functions.iter().find(|f| f.name == "transfer"); + assert!(func.is_some(), "transfer function not found"); + let func = func.unwrap(); + assert!(func.doc.contains("Transfer tokens")); + assert_eq!(func.params.len(), 4); + assert!(func.return_type.is_some()); + } + + #[test] + fn extracts_struct_fields() { + let doc = extract_from_source(SAMPLE, PathBuf::from("test.rs")); + let st = doc.structs.iter().find(|s| s.name == "Config"); + assert!(st.is_some(), "Config struct not found"); + let st = st.unwrap(); + assert_eq!(st.fields.len(), 2); + assert!(st.fields[0].doc.contains("Administrator")); + } + + #[test] + fn extracts_enum_variants() { + let doc = extract_from_source(SAMPLE, PathBuf::from("test.rs")); + let en = doc.enums.iter().find(|e| e.name == "ContractError"); + assert!(en.is_some(), "ContractError enum not found"); + let en = en.unwrap(); + assert_eq!(en.variants.len(), 2); + } + + #[test] + fn extracts_code_examples() { + let doc = extract_from_source(SAMPLE, PathBuf::from("test.rs")); + let func = doc.functions.iter().find(|f| f.name == "transfer").unwrap(); + assert!(!func.examples.is_empty()); + assert!(func.examples[0].contains("transfer")); + } +} diff --git a/src/utils/doc_html.rs b/src/utils/doc_html.rs new file mode 100644 index 00000000..03869a07 --- /dev/null +++ b/src/utils/doc_html.rs @@ -0,0 +1,417 @@ +//! HTML documentation generator for Soroban contracts. +//! +//! Takes a [`crate::utils::docs::DocEntry`] (or the raw extracted types) and +//! produces a self-contained HTML reference site. Rendering is driven by the +//! [`crate::utils::doc_templates`] template system so the output can be +//! customised without touching this module. + +use anyhow::Result; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::utils::doc_templates::{TemplateContext, TemplateKind, TemplateManager}; +use crate::utils::docs::{DocEntry, FunctionDoc, EventDoc, StorageDoc}; + +// ────────────────────────────────────────────────────────────────────────────── +// Public API +// ────────────────────────────────────────────────────────────────────────────── + +/// Generate a full HTML site for `entry` and write it to `output_dir`. +/// +/// Produces: +/// - `/.html` — per-contract reference page +/// - `/index.html` — updated portal index +pub fn generate_html_site( + entry: &DocEntry, + output_dir: &Path, + custom_template_dir: Option<&Path>, +) -> Result { + fs::create_dir_all(output_dir)?; + + let mut mgr = match custom_template_dir { + Some(dir) => TemplateManager::with_custom_dir(dir.to_path_buf()), + None => TemplateManager::new(), + }; + + let ctx = build_context(entry); + + // Per-contract page. + let page = mgr.render(&TemplateKind::HtmlFull, &ctx)?; + let page_path = output_dir.join(format!("{}.html", sanitise_id(&entry.contract_id))); + fs::write(&page_path, &page)?; + + // Regenerate the portal index. + regenerate_index(output_dir, entry, &mut mgr)?; + + Ok(page_path) +} + +/// Render a single contract page as an HTML string (no disk I/O). +pub fn render_contract_html( + entry: &DocEntry, + custom_template_dir: Option<&Path>, +) -> Result { + let mut mgr = match custom_template_dir { + Some(dir) => TemplateManager::with_custom_dir(dir.to_path_buf()), + None => TemplateManager::new(), + }; + let ctx = build_context(entry); + mgr.render(&TemplateKind::HtmlFull, &ctx) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Context builder +// ────────────────────────────────────────────────────────────────────────────── + +fn build_context(entry: &DocEntry) -> TemplateContext { + TemplateContext { + name: entry.name.clone(), + contract_id: entry.contract_id.clone(), + description: entry.description.clone(), + version: entry.version.clone(), + author: String::new(), + network: entry.network.clone(), + generated_at: entry.generated_at[..10].to_string(), + functions_html: render_functions_html(&entry.api.functions), + events_html: render_events_html(&entry.api.events), + storage_html: render_storage_html(&entry.api.storage), + examples_html: render_examples_html(&entry.api.functions), + ..Default::default() + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Block renderers — produce HTML snippets injected into templates +// ────────────────────────────────────────────────────────────────────────────── + +fn render_functions_html(functions: &[FunctionDoc]) -> String { + if functions.is_empty() { + return "

No public functions documented.

".to_string(); + } + + let mut html = String::new(); + for func in functions { + let params_rows: String = func + .parameters + .iter() + .map(|p| { + let req = if p.required { "required" } else { "optional" }; + format!( + "{}{}{}{}", + escape_html(&p.name), + escape_html(&p.ty), + escape_html(&p.description), + req + ) + }) + .collect(); + + let params_section = if func.parameters.is_empty() { + String::new() + } else { + format!( + r#" + + {} +
NameTypeDescriptionRequired
"#, + params_rows + ) + }; + + let returns_section = func + .returns + .as_ref() + .map(|r| { + format!( + "

Returns: {}

", + escape_html(r) + ) + }) + .unwrap_or_default(); + + let examples_section: String = func + .examples + .iter() + .map(|ex| { + format!( + "
{}
", + escape_html(ex) + ) + }) + .collect(); + + html.push_str(&format!( + r#"
+
{name} public
+
{desc}
+ {params} + {returns} + {examples} +
"#, + id = escape_html(&func.name), + name = escape_html(&func.name), + desc = escape_html(&func.description), + params = params_section, + returns = returns_section, + examples = examples_section, + )); + } + html +} + +fn render_events_html(events: &[EventDoc]) -> String { + if events.is_empty() { + return "

No events documented.

".to_string(); + } + + let mut html = String::new(); + for event in events { + let topics: String = event + .topics + .iter() + .map(|t| { + format!( + "
  • {} ({}): {}
  • ", + escape_html(&t.name), + escape_html(&t.ty), + escape_html(&t.description) + ) + }) + .collect(); + + html.push_str(&format!( + r#"
    +
    {name}
    +
    {desc}
    + {topics} +
    "#, + id = escape_html(&event.name), + name = escape_html(&event.name), + desc = escape_html(&event.description), + topics = if topics.is_empty() { + String::new() + } else { + format!("
      {}
    ", topics) + }, + )); + } + html +} + +fn render_storage_html(storage: &[StorageDoc]) -> String { + if storage.is_empty() { + return "

    No storage keys documented.

    ".to_string(); + } + + let rows: String = storage + .iter() + .map(|s| { + format!( + "{}{}{}", + escape_html(&s.key), + escape_html(&s.ty), + escape_html(&s.description) + ) + }) + .collect(); + + format!( + r#" + + {} +
    KeyTypeDescription
    "#, + rows + ) +} + +fn render_examples_html(functions: &[FunctionDoc]) -> String { + let examples: Vec = functions + .iter() + .flat_map(|f| { + f.examples.iter().map(move |ex| { + format!( + r#"
    +

    Example — {}

    +
    {}
    +
    "#, + escape_html(&f.name), + escape_html(ex) + ) + }) + }) + .collect(); + + if examples.is_empty() { + "

    No usage examples available.

    ".to_string() + } else { + examples.join("\n") + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Portal index +// ────────────────────────────────────────────────────────────────────────────── + +fn regenerate_index( + output_dir: &Path, + new_entry: &DocEntry, + mgr: &mut TemplateManager, +) -> Result<()> { + // Scan existing HTML files (excluding index.html) to build the card list. + let mut cards = String::new(); + + // Add/update card for the new entry. + let new_ctx = build_context(new_entry); + cards.push_str(&mgr.render(&TemplateKind::HtmlCard, &new_ctx)?); + + // Read existing cards from previously generated pages. + if let Ok(entries) = fs::read_dir(output_dir) { + for entry in entries.flatten() { + let path = entry.path(); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + if path.extension().and_then(|e| e.to_str()) == Some("html") + && stem != "index" + && stem != sanitise_id(&new_entry.contract_id) + { + // We can't re-parse DocEntry from HTML, so skip re-rendering old cards — + // just note the file exists as a plain link. + cards.push_str(&format!( + r#""# + )); + } + } + } + + let index_html = build_portal_index_html(&cards); + fs::write(output_dir.join("index.html"), index_html)?; + Ok(()) +} + +fn build_portal_index_html(cards: &str) -> String { + format!( + r#" + + + + + StarForge Contract Documentation Portal + + + +

    ⚡ StarForge Contract Documentation Portal

    +

    Explore documented Soroban contracts

    + +
    + {cards} +
    + + +"#, + cards = cards + ) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +fn escape_html(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn sanitise_id(id: &str) -> String { + id.replace('/', "_").replace(' ', "_") +} + +// ────────────────────────────────────────────────────────────────────────────── +// Tests +// ────────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::docs::{ApiDocumentation, DocEntry, DocSection, FunctionDoc, ParamDoc}; + + fn sample_entry() -> DocEntry { + DocEntry { + contract_id: "CABC1234".to_string(), + name: "TokenContract".to_string(), + description: "A test token contract".to_string(), + version: "1.0.0".to_string(), + network: "testnet".to_string(), + generated_at: "2026-01-01T00:00:00Z".to_string(), + sections: vec![DocSection { + title: "Overview".to_string(), + content: "Token overview.".to_string(), + order: 0, + }], + api: ApiDocumentation { + functions: vec![FunctionDoc { + name: "transfer".to_string(), + description: "Transfer tokens".to_string(), + parameters: vec![ParamDoc { + name: "amount".to_string(), + ty: "i128".to_string(), + description: "Amount to transfer".to_string(), + required: true, + }], + returns: Some("bool".to_string()), + examples: vec!["contract.transfer(100)".to_string()], + }], + events: vec![], + storage: vec![], + }, + } + } + + #[test] + fn renders_contract_html() { + let html = render_contract_html(&sample_entry(), None).unwrap(); + assert!(html.contains("TokenContract")); + assert!(html.contains("CABC1234")); + assert!(html.contains("transfer")); + assert!(html.contains("")); + } + + #[test] + fn escape_html_encodes_special_chars() { + assert_eq!(escape_html("