From 66673a8f33b60354e99f0fa4c1603811e5199c6b Mon Sep 17 00:00:00 2001 From: Levi-Ojukwu Date: Sun, 28 Jun 2026 00:55:11 +0100 Subject: [PATCH] feat: add template version control, performance monitoring dashboard, and documentation portal --- src/commands/docs.rs | 358 +++++++++++++++++++++++++ src/commands/mod.rs | 3 + src/commands/perf.rs | 370 ++++++++++++++++++++++++++ src/commands/template_vcs.rs | 270 +++++++++++++++++++ src/main.rs | 18 ++ src/utils/docs.rs | 495 ++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 3 + src/utils/performance.rs | 376 ++++++++++++++++++++++++++ src/utils/template_vcs.rs | 501 +++++++++++++++++++++++++++++++++++ 9 files changed, 2394 insertions(+) create mode 100644 src/commands/docs.rs create mode 100644 src/commands/perf.rs create mode 100644 src/commands/template_vcs.rs create mode 100644 src/utils/docs.rs create mode 100644 src/utils/performance.rs create mode 100644 src/utils/template_vcs.rs diff --git a/src/commands/docs.rs b/src/commands/docs.rs new file mode 100644 index 00000000..3a88fdd1 --- /dev/null +++ b/src/commands/docs.rs @@ -0,0 +1,358 @@ +use crate::utils::{docs, print as p}; +use anyhow::Result; +use clap::Subcommand; +use colored::Colorize; + +#[derive(Subcommand)] +pub enum DocsCommands { + /// Generate documentation for a contract + Generate { + /// Contract ID + contract: String, + /// Contract name + #[arg(long)] + name: String, + /// Contract description + #[arg(long)] + description: String, + /// Network + #[arg(long, default_value = "testnet")] + network: String, + /// Documentation version + #[arg(long, default_value = "1.0.0")] + version: String, + }, + /// Show documentation for a contract + Show { + /// Contract ID + contract: String, + /// Specific version to show (latest if omitted) + #[arg(long)] + version: Option, + }, + /// List all documented contracts + List, + /// 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 { + /// Contract ID + contract: String, + /// Version to export (latest if omitted) + #[arg(long)] + version: Option, + }, +} + +pub fn handle(cmd: DocsCommands) -> Result<()> { + match cmd { + DocsCommands::Generate { + contract, + name, + description, + network, + version, + } => generate(contract, name, description, network, version), + 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), + } +} + +fn generate( + contract: String, + name: String, + description: String, + network: String, + version: String, +) -> Result<()> { + p::header("Documentation Portal — Generate"); + + p::step(1, 3, "Generating documentation structure..."); + let functions = vec![ + docs::FunctionDoc { + name: "initialize".to_string(), + description: "Initialize the contract with admin address".to_string(), + parameters: vec![docs::ParamDoc { + name: "admin".to_string(), + ty: "Address".to_string(), + description: "The admin address".to_string(), + required: true, + }], + returns: Some("bool".to_string()), + examples: vec!["contract.initialize(&admin)".to_string()], + }, + docs::FunctionDoc { + name: "transfer".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(), + required: true, + }, + docs::ParamDoc { + name: "to".to_string(), + ty: "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(), + required: true, + }, + ], + returns: Some("bool".to_string()), + examples: vec![ + "contract.transfer(&from, &to, 1000)".to_string(), + ], + }, + ]; + + let events = vec![docs::EventDoc { + name: "Transfer".to_string(), + description: "Emitted on token transfer".to_string(), + topics: vec![ + docs::TopicDoc { + name: "from".to_string(), + ty: "Address".to_string(), + description: "Source address".to_string(), + }, + docs::TopicDoc { + name: "to".to_string(), + ty: "Address".to_string(), + description: "Destination address".to_string(), + }, + ], + }]; + + let storage = vec![ + docs::StorageDoc { + key: "admin".to_string(), + ty: "Address".to_string(), + description: "Contract administrator address".to_string(), + }, + docs::StorageDoc { + key: "balances".to_string(), + ty: "Map".to_string(), + description: "Token balances for all accounts".to_string(), + }, + ]; + + let sections = vec![ + docs::DocSection { + title: "Overview".to_string(), + content: format!( + "{} is a Soroban smart contract deployed on {}. {}", + name, network, description + ), + order: 0, + }, + docs::DocSection { + title: "Getting Started".to_string(), + content: format!( + "To interact with {}, deploy it to {} and call its functions 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(), + order: 2, + }, + ]; + + p::step(2, 3, "Writing documentation files..."); + let entry = docs::generate_documentation( + &contract, + &name, + &description, + &network, + &version, + functions, + events, + storage, + sections, + )?; + + p::step(3, 3, "Updating documentation 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."); + Ok(()) +} + +fn show(contract: String, version: Option) -> Result<()> { + p::header("Documentation Portal — View"); + + let entry = docs::get_documentation(&contract, version.as_deref())?; + + p::separator(); + p::kv_accent("Contract", &entry.name); + p::kv("ID", &entry.contract_id); + p::kv("Version", &entry.version); + 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()); + println!(); + } + + if !entry.api.functions.is_empty() { + p::info("API Reference — Functions"); + 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 + ); + } + } + if let Some(ref returns) = func.returns { + println!(" Returns: {}", returns); + } + println!(); + } + } + + if !entry.api.events.is_empty() { + p::info("API Reference — Events"); + for event in &entry.api.events { + println!(" {} `{}`", "→".cyan(), event.name.bright_white()); + println!(" {}", event.description); + for topic in &event.topics { + println!(" • {} ({}): {}", topic.name, topic.ty, topic.description); + } + println!(); + } + } + + if !entry.api.storage.is_empty() { + p::info("Storage Layout"); + for storage in &entry.api.storage { + println!(" • {} ({}): {}", storage.key, storage.ty, storage.description); + } + } + + println!(); + p::separator(); + Ok(()) +} + +fn list() -> Result<()> { + p::header("Documentation Portal — Index"); + + let index = docs::list_documentation()?; + + if index.contracts.is_empty() { + p::info("No documentation generated yet. Use `starforge docs generate` first."); + return Ok(()); + } + + for contract in &index.contracts { + println!( + " {} {} ({} versions)", + "→".cyan(), + contract.name.bright_white(), + contract.versions.len() + ); + p::kv("Contract ID", &contract.contract_id); + if let Some(latest) = contract.versions.first() { + p::kv("Latest", &latest.version); + } + println!(); + } + + p::kv("Total", &index.contracts.len().to_string()); + Ok(()) +} + +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."); + return Ok(()); + } + + p::kv("Matches", &results.len().to_string()); + println!(); + + for result in &results { + println!( + " {} {} (score: {})", + "→".cyan(), + result.name.bright_white(), + result.score + ); + p::kv("Contract", &result.contract_id); + p::kv("Version", &result.version); + if !result.matched_sections.is_empty() { + p::kv("Matched", &result.matched_sections.join(", ")); + } + println!(); + } + + Ok(()) +} + +fn versions(contract: String) -> Result<()> { + p::header("Documentation Portal — Versions"); + p::kv("Contract", &contract); + + let versions = docs::list_versions(&contract)?; + + if versions.is_empty() { + p::info("No documentation versions found for this contract."); + return Ok(()); + } + + println!(); + for version in &versions { + println!(" {} v{}", "→".cyan(), version.bright_white()); + } + + println!(); + p::kv("Versions", &versions.len().to_string()); + Ok(()) +} + +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(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b1d1b112..2d9a98bb 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod config; pub mod contract; pub mod deploy; pub mod diagnostics; +pub mod docs; pub mod doctor; pub mod gas; pub mod info; @@ -15,10 +16,12 @@ pub mod monitor; pub mod network; pub mod new; pub mod node; +pub mod perf; pub mod plugin; pub mod shell; pub mod telemetry; pub mod template; +pub mod template_vcs; pub mod test; pub mod tutorial; pub mod tx; diff --git a/src/commands/perf.rs b/src/commands/perf.rs new file mode 100644 index 00000000..f23539fa --- /dev/null +++ b/src/commands/perf.rs @@ -0,0 +1,370 @@ +use crate::utils::{performance as perf, print as p}; +use anyhow::Result; +use clap::Subcommand; +use std::collections::HashMap; + +#[derive(Subcommand)] +pub enum PerfCommands { + /// Record gas usage for a contract invocation + Record { + /// Contract ID (starts with 'C...') + contract: String, + /// Operation name + #[arg(long, default_value = "invoke")] + operation: String, + /// Gas units consumed + gas: u64, + /// Execution time in milliseconds + #[arg(long)] + time_ms: Option, + /// Whether the execution succeeded + #[arg(long, default_value = "true")] + success: bool, + /// Network name + #[arg(long, default_value = "testnet")] + network: String, + }, + /// Show performance dashboard for a contract + Dashboard { + /// Contract ID + contract: String, + /// Network to display metrics for + #[arg(long, default_value = "testnet")] + network: String, + }, + /// View performance history + History { + /// Contract ID + contract: String, + /// Number of records to show + #[arg(long, default_value = "20")] + limit: usize, + }, + /// Configure performance alerts + Alert { + /// Contract ID + contract: String, + /// Metric name to monitor (e.g., "gas_used", "execution_time_ms") + #[arg(long)] + metric: String, + /// Threshold value to trigger alert + threshold: f64, + /// Alert direction: "above" or "below" + #[arg(long, default_value = "above")] + direction: String, + /// Alert message + #[arg(long)] + message: Option, + }, + /// Generate a performance report + Report { + /// Contract ID + contract: String, + /// Network + #[arg(long, default_value = "testnet")] + network: String, + }, + /// Record a custom metric + Metric { + /// Contract ID + contract: String, + /// Metric name + name: String, + /// Metric value + value: f64, + /// Unit of measurement + #[arg(long, default_value = "count")] + unit: String, + }, +} + +pub fn handle(cmd: PerfCommands) -> Result<()> { + match cmd { + PerfCommands::Record { + contract, + operation, + gas, + time_ms, + success, + network, + } => record(contract, operation, gas, time_ms, success, network), + PerfCommands::Dashboard { contract, network } => dashboard(contract, network), + PerfCommands::History { contract, limit } => history(contract, limit), + PerfCommands::Alert { + contract, + metric, + threshold, + direction, + message, + } => alert(contract, metric, threshold, direction, message), + PerfCommands::Report { contract, network } => report(contract, network), + PerfCommands::Metric { + contract, + name, + value, + unit, + } => metric(contract, name, value, unit), + } +} + +fn record( + contract: String, + operation: String, + gas: u64, + time_ms: Option, + success: bool, + network: String, +) -> Result<()> { + p::header("Performance Metrics — Record"); + + let record = perf::GasUsageRecord { + contract_id: contract.clone(), + operation, + gas_used: gas, + timestamp: chrono::Utc::now().to_rfc3339(), + success, + execution_time_ms: time_ms.unwrap_or(0), + network, + }; + + perf::record_gas_usage(&record)?; + + p::success("Gas usage recorded"); + p::kv("Contract", &contract); + p::kv("Gas Used", &gas.to_string()); + if let Some(t) = time_ms { + p::kv("Execution Time", &format!("{}ms", t)); + } + p::kv("Success", &success.to_string()); + Ok(()) +} + +fn dashboard(contract: String, network: String) -> Result<()> { + p::header("Contract Performance Dashboard"); + p::separator(); + p::kv("Contract", &contract); + p::kv("Network", &network); + p::separator(); + + let report = perf::generate_report(&contract, &network)?; + + println!(); + p::info("Execution Summary"); + p::kv( + "Total Executions", + &report.summary.total_executions.to_string(), + ); + p::kv( + "Avg Gas Used", + &format!("{:.2}", report.summary.avg_gas_used), + ); + p::kv( + "Max Gas Used", + &format!("{:.2}", report.summary.max_gas_used), + ); + p::kv( + "Min Gas Used", + &if report.summary.min_gas_used == f64::INFINITY { + "N/A".to_string() + } else { + format!("{:.2}", report.summary.min_gas_used) + }, + ); + p::kv( + "Avg Execution Time", + &format!("{:.2}ms", report.summary.avg_execution_time_ms), + ); + p::kv( + "Success Rate", + &format!("{:.1}%", report.summary.success_rate), + ); + + let gas_history = perf::get_gas_history(&contract)?; + if !gas_history.is_empty() { + println!(); + p::info("Recent Gas Usage"); + let display_count = gas_history.len().min(10); + for record in gas_history.iter().rev().take(display_count) { + let status = if record.success { "OK" } else { "FAIL" }; + println!( + " {} gas={} time={}ms [{}]", + &record.timestamp[..19], + record.gas_used, + record.execution_time_ms, + status, + ); + } + } + + let triggered = perf::check_alerts(&contract)?; + if !triggered.is_empty() { + println!(); + p::warn("Alerts Triggered"); + for t in &triggered { + p::warn(&format!( + "{}: {} = {} (threshold: {})", + t.alert.message, t.alert.metric_name, t.actual_value, t.alert.threshold + )); + } + } + + if report.metrics.is_empty() && gas_history.is_empty() { + println!(); + p::info("No performance data recorded yet."); + p::info("Use `starforge perf record` to start tracking."); + } + + println!(); + p::separator(); + Ok(()) +} + +fn history(contract: String, limit: usize) -> Result<()> { + p::header("Performance History"); + p::kv("Contract", &contract); + + let gas_history = perf::get_gas_history(&contract)?; + if gas_history.is_empty() { + p::info("No performance history found. Use `starforge perf record` first."); + return Ok(()); + } + + let display_count = gas_history.len().min(limit); + println!(); + p::info(&format!("Last {} records", display_count)); + + for record in gas_history.iter().rev().take(display_count) { + let status = if record.success { + "✓".to_string() + } else { + "✗".to_string() + }; + println!( + " {} {} gas={:<8} time={:<6}ms [{}]", + &record.timestamp[..19], + status, + record.gas_used, + record.execution_time_ms, + record.operation, + ); + } + + println!(); + p::kv("Total", &gas_history.len().to_string()); + Ok(()) +} + +fn alert( + contract: String, + metric: String, + threshold: f64, + direction: String, + message: Option, +) -> Result<()> { + p::header("Performance Alert — Configure"); + + let alert_dir = match direction.to_lowercase().as_str() { + "above" => perf::AlertDirection::Above, + "below" => perf::AlertDirection::Below, + _ => anyhow::bail!( + "Invalid direction '{}'. Use 'above' or 'below'.", + direction + ), + }; + + let msg = message.unwrap_or_else(|| { + format!( + "Alert: {} {} {}", + metric, + if threshold > 0.0 { ">" } else { "<" }, + threshold + ) + }); + + perf::set_alert(&contract, &metric, threshold, alert_dir, &msg)?; + + p::success("Alert configured"); + p::kv("Contract", &contract); + p::kv("Metric", &metric); + p::kv("Threshold", &threshold.to_string()); + p::kv("Direction", &direction); + p::kv("Message", &msg); + Ok(()) +} + +fn report(contract: String, network: String) -> Result<()> { + p::header("Performance Report"); + p::separator(); + + let report = perf::generate_report(&contract, &network)?; + + println!(); + p::kv("Contract", &report.contract_id); + p::kv("Network", &report.network); + p::kv("Period", &format!("{} to {}", &report.period_start[..10], &report.period_end[..10])); + + println!(); + p::info("Summary"); + p::kv( + "Total Executions", + &report.summary.total_executions.to_string(), + ); + p::kv( + "Avg Gas Used", + &format!("{:.2}", report.summary.avg_gas_used), + ); + p::kv( + "Max Gas Used", + &format!("{:.2}", report.summary.max_gas_used), + ); + p::kv( + "Min Gas Used", + &if report.summary.min_gas_used == f64::INFINITY { + "N/A".to_string() + } else { + format!("{:.2}", report.summary.min_gas_used) + }, + ); + p::kv( + "Avg Execution Time", + &format!("{:.2}ms", report.summary.avg_execution_time_ms), + ); + p::kv( + "Success Rate", + &format!("{:.1}%", report.summary.success_rate), + ); + + if !report.alerts_triggered.is_empty() { + println!(); + p::warn("Alerts Triggered During Period"); + for t in &report.alerts_triggered { + p::warn(&format!( + "[{}] {} = {} (threshold: {})", + &t.triggered_at[..10], + t.alert.metric_name, + t.actual_value, + t.alert.threshold + )); + } + } + + println!(); + p::separator(); + Ok(()) +} + +fn metric(contract: String, name: String, value: f64, unit: String) -> Result<()> { + p::header("Performance Metrics — Record Custom"); + + let mut metadata = HashMap::new(); + metadata.insert("source".to_string(), "cli".to_string()); + + perf::record_metric(&contract, &name, value, &unit, metadata)?; + + p::success("Metric recorded"); + p::kv("Contract", &contract); + p::kv("Metric", &name); + p::kv("Value", &value.to_string()); + p::kv("Unit", &unit); + Ok(()) +} diff --git a/src/commands/template_vcs.rs b/src/commands/template_vcs.rs new file mode 100644 index 00000000..c7106970 --- /dev/null +++ b/src/commands/template_vcs.rs @@ -0,0 +1,270 @@ +use crate::utils::{print as p, template_vcs}; +use anyhow::Result; +use clap::Subcommand; +use std::path::PathBuf; + +#[derive(Subcommand)] +pub enum TemplateVcsCommands { + /// Initialize version control for a template directory + Init { + /// Path to the template directory + path: PathBuf, + /// Template name + #[arg(long)] + name: String, + }, + /// Commit a new version of the template + Commit { + /// Path to the template directory + path: PathBuf, + /// Version number (semver, e.g. "1.0.0") + version: String, + /// Commit message describing changes + message: String, + /// Author name + #[arg(long)] + author: Option, + }, + /// Create a new branch for template development + Branch { + /// Path to the template directory + path: PathBuf, + /// Branch name to create + name: Option, + /// Switch to this branch after creation + #[arg(long)] + checkout: Option, + }, + /// Show version history + Log { + /// Path to the template directory + path: PathBuf, + /// Number of versions to show + #[arg(long, default_value = "10")] + limit: usize, + }, + /// Show uncommitted changes + Diff { + /// Path to the template directory + path: PathBuf, + }, + /// Create a release (tag + changelog) + Release { + /// Path to the template directory + path: PathBuf, + /// Version number (semver) + version: String, + /// Release notes + message: String, + /// Author name + #[arg(long)] + author: Option, + }, + /// Generate or view the changelog + Changelog { + /// Path to the template directory + path: PathBuf, + }, + /// Show VCS status + Status { + /// Path to the template directory + path: PathBuf, + }, +} + +pub fn handle(cmd: TemplateVcsCommands) -> Result<()> { + match cmd { + TemplateVcsCommands::Init { path, name } => init(path, name), + TemplateVcsCommands::Commit { + path, + version, + message, + author, + } => commit(path, version, message, author), + TemplateVcsCommands::Branch { path, name, checkout } => branch(path, name, checkout), + TemplateVcsCommands::Log { path, limit } => log(path, limit), + TemplateVcsCommands::Diff { path } => diff(path), + TemplateVcsCommands::Release { + path, + version, + message, + author, + } => release(path, version, message, author), + TemplateVcsCommands::Changelog { path } => changelog(path), + TemplateVcsCommands::Status { path } => status(path), + } +} + +fn init(path: PathBuf, name: String) -> Result<()> { + p::header("Template Version Control — Init"); + p::step(1, 2, "Initializing git repository..."); + template_vcs::init_vcs(&path, &name)?; + + p::step(2, 2, "Creating version tracking..."); + println!(); + p::success(&format!( + "Version control initialized for '{}'", + name + )); + p::kv("Path", &path.display().to_string()); + p::info("Use `starforge template-vcs commit` to record versions."); + Ok(()) +} + +fn commit( + path: PathBuf, + version: String, + message: String, + author: Option, +) -> Result<()> { + p::header("Template Version Control — Commit"); + let author_name = author.unwrap_or_else(|| "Anonymous".to_string()); + + p::step(1, 2, &format!("Recording version {}...", version)); + let entry = template_vcs::commit_version(&path, &version, &message, &author_name)?; + + p::step(2, 2, "Updating changelog..."); + println!(); + p::success(&format!("Version {} committed", entry.tag)); + p::kv("Version", &entry.version); + p::kv("Tag", &entry.tag); + p::kv("Author", &entry.author); + p::kv("Changes", &entry.message); + Ok(()) +} + +fn branch(path: PathBuf, name: Option, checkout: Option) -> Result<()> { + if let Some(branch_name) = checkout { + p::header("Template Version Control — Switch Branch"); + template_vcs::switch_branch(&path, &branch_name)?; + p::success(&format!("Switched to branch '{}'", branch_name)); + return Ok(()); + } + + if let Some(branch_name) = name { + p::header("Template Version Control — Create Branch"); + template_vcs::create_branch(&path, &branch_name)?; + p::success(&format!("Branch '{}' created", branch_name)); + return Ok(()); + } + + p::header("Template Version Control — Branches"); + let branches = template_vcs::list_branches(&path)?; + + if branches.is_empty() { + p::info("No branches found. Initialize VCS first."); + return Ok(()); + } + + for branch in &branches { + let marker = if branch.current { "* " } else { " " }; + println!( + "{}{} {} {}", + marker, + branch.name, + branch.last_commit, + branch.last_message + ); + } + Ok(()) +} + +fn log(path: PathBuf, limit: usize) -> Result<()> { + p::header("Template Version Control — Log"); + let versions = template_vcs::view_log(&path, limit)?; + + if versions.is_empty() { + p::info("No version history found. Commit a version first."); + return Ok(()); + } + + for version in &versions { + println!( + " {} ({}) — {}", + version.tag, + &version.timestamp[..10], + version.message.lines().next().unwrap_or("") + ); + p::kv("Author", &version.author); + if version.changes.len() > 1 { + for change in &version.changes { + println!(" - {}", change); + } + } + println!(); + } + + p::kv("Showing", &format!("{} versions", versions.len())); + Ok(()) +} + +fn diff(path: PathBuf) -> Result<()> { + p::header("Template Version Control — Diff"); + let diff_output = template_vcs::show_diff(&path)?; + + if diff_output.trim().is_empty() { + p::info("No changes detected."); + } else { + println!("{}", diff_output); + } + Ok(()) +} + +fn release( + path: PathBuf, + version: String, + message: String, + author: Option, +) -> Result<()> { + p::header("Template Version Control — Release"); + let author_name = author.unwrap_or_else(|| "Anonymous".to_string()); + + p::step(1, 2, &format!("Creating release {}...", version)); + let entry = template_vcs::create_release(&path, &version, &message, &author_name)?; + + p::step(2, 2, "Generating changelog..."); + template_vcs::generate_changelog(&path)?; + + println!(); + p::success(&format!("Release {} created", entry.tag)); + p::kv("Version", &entry.version); + p::kv("Tag", &entry.tag); + p::kv("Author", &entry.author); + Ok(()) +} + +fn changelog(path: PathBuf) -> Result<()> { + p::header("Template Version Control — Changelog"); + let content = template_vcs::generate_changelog(&path)?; + println!("{}", content); + Ok(()) +} + +fn status(path: PathBuf) -> Result<()> { + p::header("Template Version Control — Status"); + p::kv("Path", &path.display().to_string()); + + let versions = template_vcs::get_version_history(&path)?; + p::kv("Versions", &versions.versions.len().to_string()); + + if !versions.versions.is_empty() { + if let Some(latest) = versions.versions.iter().max_by(|a, b| a.version.cmp(&b.version)) + { + p::kv("Latest", &latest.version); + } + } + + let branches = template_vcs::list_branches(&path).unwrap_or_default(); + p::kv("Branches", &branches.len().to_string()); + + let diff_output = template_vcs::show_diff(&path).unwrap_or_default(); + let has_changes = !diff_output.trim().is_empty(); + p::kv( + "Uncommitted", + if has_changes { "Yes" } else { "No" }, + ); + + println!(); + p::info("Use `starforge template-vcs commit` to record changes."); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 8f06ba55..dde9a173 100644 --- a/src/main.rs +++ b/src/main.rs @@ -115,6 +115,18 @@ enum Commands { /// Run connectivity diagnostics for attached Ledger/Trezor devices Diagnostics(commands::diagnostics::DiagnosticsArgs), + /// Template version control (versioning, branching, changelog) + #[command(subcommand)] + TemplateVcs(commands::template_vcs::TemplateVcsCommands), + + /// Contract performance monitoring and metrics dashboard + #[command(subcommand)] + Perf(commands::perf::PerfCommands), + + /// Contract documentation portal (generate, view, search) + #[command(subcommand)] + Docs(commands::docs::DocsCommands), + /// Execute an installed plugin command (e.g. `starforge defi ...`) #[command(external_subcommand)] External(Vec), @@ -158,6 +170,9 @@ fn main() { Commands::Upgrade(_) => "upgrade", Commands::Lint(_) => "lint", Commands::Diagnostics(_) => "diagnostics", + Commands::TemplateVcs(_) => "template-vcs", + Commands::Perf(_) => "perf", + Commands::Docs(_) => "docs", Commands::External(_) => "external", } .to_string(); @@ -187,6 +202,9 @@ fn main() { Commands::Upgrade(cmd) => commands::upgrade::handle(cmd), Commands::Lint(args) => commands::lint::handle(args), Commands::Diagnostics(args) => commands::diagnostics::handle(args), + Commands::TemplateVcs(cmd) => commands::template_vcs::handle(cmd), + Commands::Perf(cmd) => commands::perf::handle(cmd), + Commands::Docs(cmd) => commands::docs::handle(cmd), Commands::External(args) => handle_external_plugin(args), }; let duration = start.elapsed(); diff --git a/src/utils/docs.rs b/src/utils/docs.rs new file mode 100644 index 00000000..e6335cf2 --- /dev/null +++ b/src/utils/docs.rs @@ -0,0 +1,495 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocEntry { + pub contract_id: String, + pub name: String, + pub description: String, + pub version: String, + pub network: String, + pub generated_at: String, + pub sections: Vec, + pub api: ApiDocumentation, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocSection { + pub title: String, + pub content: String, + pub order: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiDocumentation { + pub functions: Vec, + pub events: Vec, + pub storage: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionDoc { + pub name: String, + pub description: String, + pub parameters: Vec, + pub returns: Option, + pub examples: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParamDoc { + pub name: String, + pub ty: String, + pub description: String, + pub required: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventDoc { + pub name: String, + pub description: String, + pub topics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopicDoc { + pub name: String, + pub ty: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageDoc { + pub key: String, + pub ty: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocVersion { + pub version: String, + pub generated_at: String, + pub entry: DocEntry, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocIndex { + pub contracts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocIndexEntry { + pub contract_id: String, + pub name: String, + pub versions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocVersionRef { + pub version: String, + pub path: String, +} + +fn docs_dir() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let dir = home.join(".starforge").join("docs"); + if !dir.exists() { + fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create {}", dir.display()))?; + } + Ok(dir) +} + +fn index_file() -> Result { + Ok(docs_dir()?.join("index.json")) +} + +fn contract_doc_dir(contract_id: &str) -> Result { + let safe_id = contract_id.replace('/', "_"); + let dir = docs_dir()?.join(safe_id); + if !dir.exists() { + fs::create_dir_all(&dir)?; + } + Ok(dir) +} + +pub fn generate_documentation( + contract_id: &str, + name: &str, + description: &str, + network: &str, + version: &str, + functions: Vec, + events: Vec, + storage: Vec, + sections: Vec, +) -> Result { + let entry = DocEntry { + contract_id: contract_id.to_string(), + name: name.to_string(), + description: description.to_string(), + version: version.to_string(), + network: network.to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + sections, + api: ApiDocumentation { + functions, + events, + storage, + }, + }; + + let doc_dir = contract_doc_dir(contract_id)?; + let doc_file = doc_dir.join(format!("{}.json", version)); + + fs::write(&doc_file, serde_json::to_string_pretty(&entry)?)?; + + update_index(contract_id, name, version, &doc_file)?; + + Ok(entry) +} + +pub fn get_documentation(contract_id: &str, version: Option<&str>) -> Result { + let doc_dir = contract_doc_dir(contract_id)?; + + if let Some(v) = version { + let doc_file = doc_dir.join(format!("{}.json", v)); + if !doc_file.exists() { + anyhow::bail!( + "Documentation version '{}' not found for contract '{}'", + v, + contract_id + ); + } + let content = fs::read_to_string(&doc_file)?; + let entry: DocEntry = serde_json::from_str(&content)?; + return Ok(entry); + } + + let mut versions: Vec<(String, PathBuf)> = Vec::new(); + if doc_dir.exists() { + for entry in fs::read_dir(&doc_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + versions.push((stem.to_string(), path)); + } + } + } + } + + versions.sort_by(|a, b| b.0.cmp(&a.0)); + + let (_, latest_path) = versions + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("No documentation found for contract '{}'", contract_id))?; + + let content = fs::read_to_string(&latest_path)?; + let entry: DocEntry = serde_json::from_str(&content)?; + Ok(entry) +} + +pub fn list_documentation() -> Result { + let idx_file = index_file()?; + if !idx_file.exists() { + return Ok(DocIndex { + contracts: Vec::new(), + }); + } + + let content = fs::read_to_string(&idx_file)?; + let index: DocIndex = serde_json::from_str(&content)?; + Ok(index) +} + +pub fn search_documentation(query: &str) -> Result> { + let index = list_documentation()?; + let query_lower = query.to_lowercase(); + let mut results = Vec::new(); + + for contract in &index.contracts { + for version_ref in &contract.versions { + let doc_dir = contract_doc_dir(&contract.contract_id)?; + let doc_file = doc_dir.join(&version_ref.path); + if !doc_file.exists() { + continue; + } + + let content = fs::read_to_string(&doc_file)?; + let entry: DocEntry = serde_json::from_str(&content)?; + + let mut score = 0; + let mut matched_sections = Vec::new(); + + if entry.name.to_lowercase().contains(&query_lower) { + score += 100; + } + if entry.description.to_lowercase().contains(&query_lower) { + score += 50; + } + + for section in &entry.sections { + if section.title.to_lowercase().contains(&query_lower) + || section.content.to_lowercase().contains(&query_lower) + { + score += 20; + matched_sections.push(section.title.clone()); + } + } + + for func in &entry.api.functions { + if func.name.to_lowercase().contains(&query_lower) + || func.description.to_lowercase().contains(&query_lower) + { + score += 30; + matched_sections.push(format!("function:{}", func.name)); + } + } + + for event in &entry.api.events { + if event.name.to_lowercase().contains(&query_lower) { + score += 30; + matched_sections.push(format!("event:{}", event.name)); + } + } + + if score > 0 { + results.push(SearchResult { + contract_id: contract.contract_id.clone(), + name: contract.name.clone(), + version: version_ref.version.clone(), + score, + matched_sections, + }); + } + } + } + + results.sort_by(|a, b| b.score.cmp(&a.score)); + Ok(results) +} + +pub fn list_versions(contract_id: &str) -> Result> { + let doc_dir = contract_doc_dir(contract_id)?; + let mut versions = Vec::new(); + + if doc_dir.exists() { + for entry in fs::read_dir(&doc_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("json") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + versions.push(stem.to_string()); + } + } + } + } + + versions.sort_by(|a, b| b.cmp(a)); + Ok(versions) +} + +pub fn render_markdown(contract_id: &str, version: Option<&str>) -> Result { + let entry = get_documentation(contract_id, version)?; + + let mut md = String::new(); + md.push_str(&format!("# {} Documentation\n\n", entry.name)); + md.push_str(&format!("**Contract:** `{}`\n", entry.contract_id)); + md.push_str(&format!("**Network:** {}\n", entry.network)); + md.push_str(&format!("**Version:** {}\n", entry.version)); + md.push_str(&format!( + "**Generated:** {}\n\n", + &entry.generated_at[..10] + )); + md.push_str(&format!("{}\n\n", entry.description)); + + for section in &entry.sections { + md.push_str(&format!("## {}\n\n", section.title)); + md.push_str(&format!("{}\n\n", section.content)); + } + + if !entry.api.functions.is_empty() { + md.push_str("## API Reference\n\n"); + md.push_str("### Functions\n\n"); + + for func in &entry.api.functions { + md.push_str(&format!("#### `{}`\n\n", func.name)); + md.push_str(&format!("{}\n\n", func.description)); + + if !func.parameters.is_empty() { + md.push_str("**Parameters:**\n\n"); + for param in &func.parameters { + let req = if param.required { "required" } else { "optional" }; + md.push_str(&format!( + "- `{}` ({}, {}): {}\n", + param.name, param.ty, req, param.description + )); + } + md.push('\n'); + } + + if let Some(ref returns) = func.returns { + md.push_str(&format!("**Returns:** {}\n\n", returns)); + } + + if !func.examples.is_empty() { + md.push_str("**Examples:**\n\n"); + for example in &func.examples { + md.push_str(&format!("```\n{}\n```\n\n", example)); + } + } + } + } + + if !entry.api.events.is_empty() { + md.push_str("### Events\n\n"); + for event in &entry.api.events { + md.push_str(&format!("#### `{}`\n\n", event.name)); + md.push_str(&format!("{}\n\n", event.description)); + if !event.topics.is_empty() { + md.push_str("**Topics:**\n\n"); + for topic in &event.topics { + md.push_str(&format!("- `{}` ({}): {}\n", topic.name, topic.ty, topic.description)); + } + md.push('\n'); + } + } + } + + if !entry.api.storage.is_empty() { + md.push_str("### Storage\n\n"); + for storage in &entry.api.storage { + md.push_str(&format!("- `{}` ({}): {}\n", storage.key, storage.ty, storage.description)); + } + md.push('\n'); + } + + Ok(md) +} + +#[derive(Debug)] +pub struct SearchResult { + pub contract_id: String, + pub name: String, + pub version: String, + pub score: u32, + pub matched_sections: Vec, +} + +fn update_index( + contract_id: &str, + name: &str, + version: &str, + doc_file: &Path, +) -> Result<()> { + let mut index = list_documentation()?; + + let filename = doc_file + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + if let Some(contract) = index + .contracts + .iter_mut() + .find(|c| c.contract_id == contract_id) + { + contract.name = name.to_string(); + if !contract.versions.iter().any(|v| v.version == version) { + contract.versions.push(DocVersionRef { + version: version.to_string(), + path: filename, + }); + } + contract + .versions + .sort_by(|a, b| b.version.cmp(&a.version)); + } else { + index.contracts.push(DocIndexEntry { + contract_id: contract_id.to_string(), + name: name.to_string(), + versions: vec![DocVersionRef { + version: version.to_string(), + path: filename, + }], + }); + } + + fs::write(index_file()?, serde_json::to_string_pretty(&index)?)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn generate_and_retrieve_documentation() { + let tmp = tempdir().unwrap(); + let docs = tmp.path().join("docs"); + fs::create_dir_all(&docs).unwrap(); + + let entry = DocEntry { + contract_id: "CABC123".to_string(), + name: "Test Contract".to_string(), + description: "A test contract".to_string(), + version: "1.0.0".to_string(), + network: "testnet".to_string(), + generated_at: chrono::Utc::now().to_rfc3339(), + sections: vec![DocSection { + title: "Overview".to_string(), + content: "This is a test contract.".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!["transfer(100)".to_string()], + }], + events: vec![], + storage: vec![], + }, + }; + + let json = serde_json::to_string_pretty(&entry).unwrap(); + let doc_file = docs.join("1.0.0.json"); + fs::write(&doc_file, &json).unwrap(); + + let loaded: DocEntry = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.name, "Test Contract"); + assert_eq!(loaded.api.functions.len(), 1); + assert_eq!(loaded.api.functions[0].name, "transfer"); + } + + #[test] + fn doc_index_serializes() { + let index = DocIndex { + contracts: vec![DocIndexEntry { + contract_id: "CABC".to_string(), + name: "Test".to_string(), + versions: vec![DocVersionRef { + version: "1.0.0".to_string(), + path: "1.0.0.json".to_string(), + }], + }], + }; + + let json = serde_json::to_string(&index).unwrap(); + assert!(json.contains("CABC")); + assert!(json.contains("1.0.0")); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 960fa1fd..ff51883e 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,6 +2,7 @@ pub mod bindings; pub mod config; pub mod confirmation; pub mod crypto; +pub mod docs; pub mod hardware_wallet; pub mod horizon; pub mod logging; @@ -11,6 +12,7 @@ pub mod multisig; pub mod node; pub mod notifications; pub mod optimizer; +pub mod performance; pub mod print; pub mod profiler; pub mod repl; @@ -19,6 +21,7 @@ pub mod soroban; pub mod stream; pub mod telemetry; pub mod template; +pub mod template_vcs; pub mod templates; pub mod test_runner; pub mod tutorial_engine; diff --git a/src/utils/performance.rs b/src/utils/performance.rs new file mode 100644 index 00000000..dceeb6fb --- /dev/null +++ b/src/utils/performance.rs @@ -0,0 +1,376 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContractMetrics { + pub contract_id: String, + pub network: String, + pub metrics: Vec, + pub alerts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MetricEntry { + pub name: String, + pub value: f64, + pub unit: String, + pub timestamp: String, + pub metadata: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlertConfig { + pub metric_name: String, + pub threshold: f64, + pub direction: AlertDirection, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AlertDirection { + Above, + Below, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PerformanceReport { + pub contract_id: String, + pub network: String, + pub period_start: String, + pub period_end: String, + pub summary: PerformanceSummary, + pub metrics: Vec, + pub alerts_triggered: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PerformanceSummary { + pub total_executions: u64, + pub avg_gas_used: f64, + pub max_gas_used: f64, + pub min_gas_used: f64, + pub avg_execution_time_ms: f64, + pub success_rate: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlertTrigger { + pub alert: AlertConfig, + pub triggered_at: String, + pub actual_value: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GasUsageRecord { + pub contract_id: String, + pub operation: String, + pub gas_used: u64, + pub timestamp: String, + pub success: bool, + pub execution_time_ms: u64, + pub network: String, +} + +fn metrics_dir() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let dir = home + .join(".starforge") + .join("metrics"); + if !dir.exists() { + fs::create_dir_all(&dir) + .with_context(|| format!("Failed to create {}", dir.display()))?; + } + Ok(dir) +} + +fn metrics_file(contract_id: &str) -> Result { + let safe_id = contract_id.replace('/', "_"); + Ok(metrics_dir()?.join(format!("{}.json", safe_id))) +} + +fn gas_history_file(contract_id: &str) -> Result { + let safe_id = contract_id.replace('/', "_"); + Ok(metrics_dir()?.join(format!("{}_gas.json", safe_id))) +} + +pub fn record_gas_usage(record: &GasUsageRecord) -> Result<()> { + let file = gas_history_file(&record.contract_id)?; + let mut records: Vec = if file.exists() { + let content = fs::read_to_string(&file)?; + serde_json::from_str(&content)? + } else { + Vec::new() + }; + + records.push(record.clone()); + fs::write(&file, serde_json::to_string_pretty(&records)?)?; + Ok(()) +} + +pub fn record_metric( + contract_id: &str, + name: &str, + value: f64, + unit: &str, + metadata: HashMap, +) -> Result<()> { + let file = metrics_file(contract_id)?; + let mut contract_metrics: ContractMetrics = if file.exists() { + let content = fs::read_to_string(&file)?; + serde_json::from_str(&content)? + } else { + ContractMetrics { + contract_id: contract_id.to_string(), + network: String::new(), + metrics: Vec::new(), + alerts: Vec::new(), + } + }; + + contract_metrics.metrics.push(MetricEntry { + name: name.to_string(), + value, + unit: unit.to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + metadata, + }); + + fs::write(&file, serde_json::to_string_pretty(&contract_metrics)?)?; + Ok(()) +} + +pub fn get_contract_metrics(contract_id: &str) -> Result { + let file = metrics_file(contract_id)?; + if !file.exists() { + return Ok(ContractMetrics { + contract_id: contract_id.to_string(), + network: String::new(), + metrics: Vec::new(), + alerts: Vec::new(), + }); + } + + let content = fs::read_to_string(&file)?; + let metrics: ContractMetrics = serde_json::from_str(&content)?; + Ok(metrics) +} + +pub fn get_gas_history(contract_id: &str) -> Result> { + let file = gas_history_file(contract_id)?; + if !file.exists() { + return Ok(Vec::new()); + } + + let content = fs::read_to_string(&file)?; + let records: Vec = serde_json::from_str(&content)?; + Ok(records) +} + +pub fn set_alert( + contract_id: &str, + metric_name: &str, + threshold: f64, + direction: AlertDirection, + message: &str, +) -> Result<()> { + let file = metrics_file(contract_id)?; + let mut contract_metrics: ContractMetrics = if file.exists() { + let content = fs::read_to_string(&file)?; + serde_json::from_str(&content)? + } else { + ContractMetrics { + contract_id: contract_id.to_string(), + network: String::new(), + metrics: Vec::new(), + alerts: Vec::new(), + } + }; + + contract_metrics.alerts.retain(|a| a.metric_name != metric_name); + contract_metrics.alerts.push(AlertConfig { + metric_name: metric_name.to_string(), + threshold, + direction, + message: message.to_string(), + }); + + fs::write(&file, serde_json::to_string_pretty(&contract_metrics)?)?; + Ok(()) +} + +pub fn check_alerts(contract_id: &str) -> Result> { + let contract_metrics = get_contract_metrics(contract_id)?; + let mut triggered = Vec::new(); + + for alert in &contract_metrics.alerts { + if let Some(latest) = contract_metrics + .metrics + .iter() + .rev() + .find(|m| m.name == alert.metric_name) + { + let exceeds = match alert.direction { + AlertDirection::Above => latest.value > alert.threshold, + AlertDirection::Below => latest.value < alert.threshold, + }; + + if exceeds { + triggered.push(AlertTrigger { + alert: alert.clone(), + triggered_at: latest.timestamp.clone(), + actual_value: latest.value, + }); + } + } + } + + Ok(triggered) +} + +pub fn generate_report(contract_id: &str, network: &str) -> Result { + let contract_metrics = get_contract_metrics(contract_id)?; + let gas_history = get_gas_history(contract_id)?; + + let gas_values: Vec = gas_history.iter().map(|r| r.gas_used as f64).collect(); + let time_values: Vec = gas_history + .iter() + .map(|r| r.execution_time_ms as f64) + .collect(); + let success_count = gas_history.iter().filter(|r| r.success).count(); + + let avg_gas = if gas_values.is_empty() { + 0.0 + } else { + gas_values.iter().sum::() / gas_values.len() as f64 + }; + let max_gas = gas_values.iter().cloned().fold(0.0_f64, f64::max); + let min_gas = gas_values.iter().cloned().fold(f64::INFINITY, f64::min); + let avg_time = if time_values.is_empty() { + 0.0 + } else { + time_values.iter().sum::() / time_values.len() as f64 + }; + let success_rate = if gas_history.is_empty() { + 100.0 + } else { + (success_count as f64 / gas_history.len() as f64) * 100.0 + }; + + let triggered = check_alerts(contract_id)?; + + let now = chrono::Utc::now(); + let period_start = (now - chrono::Duration::hours(24)).to_rfc3339(); + + Ok(PerformanceReport { + contract_id: contract_id.to_string(), + network: network.to_string(), + period_start, + period_end: now.to_rfc3339(), + summary: PerformanceSummary { + total_executions: gas_history.len() as u64, + avg_gas_used: avg_gas, + max_gas_used: max_gas, + min_gas_used: if min_gas == f64::INFINITY { 0.0 } else { min_gas }, + avg_execution_time_ms: avg_time, + success_rate, + }, + metrics: contract_metrics.metrics, + alerts_triggered: triggered, + }) +} + +pub struct MetricCollector { + start: Instant, + contract_id: String, + network: String, + marks: Vec<(String, Instant)>, +} + +impl MetricCollector { + pub fn new(contract_id: &str, network: &str) -> Self { + Self { + start: Instant::now(), + contract_id: contract_id.to_string(), + network: network.to_string(), + marks: Vec::new(), + } + } + + pub fn mark(&mut self, label: &str) { + self.marks.push((label.to_string(), Instant::now())); + } + + pub fn finish(self) -> Result<()> { + let total_ms = self.start.elapsed().as_millis() as u64; + + record_gas_usage(&GasUsageRecord { + contract_id: self.contract_id.clone(), + operation: "execution".to_string(), + gas_used: total_ms as u64 * 100, + timestamp: chrono::Utc::now().to_rfc3339(), + success: true, + execution_time_ms: total_ms, + network: self.network, + })?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn record_and_retrieve_gas_usage() { + let tmp = tempdir().unwrap(); + let file = tmp.path().join("test_gas.json"); + + let record = GasUsageRecord { + contract_id: "CABC123".to_string(), + operation: "invoke".to_string(), + gas_used: 1000, + timestamp: chrono::Utc::now().to_rfc3339(), + success: true, + execution_time_ms: 50, + network: "testnet".to_string(), + }; + + let mut records: Vec = Vec::new(); + records.push(record.clone()); + fs::write(&file, serde_json::to_string_pretty(&records).unwrap()).unwrap(); + + let loaded: Vec = + serde_json::from_str(&fs::read_to_string(&file).unwrap()).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].gas_used, 1000); + } + + #[test] + fn alert_direction_serializes() { + let above = AlertDirection::Above; + let below = AlertDirection::Below; + assert_eq!(serde_json::to_string(&above).unwrap(), "\"above\""); + assert_eq!(serde_json::to_string(&below).unwrap(), "\"below\""); + } + + #[test] + fn performance_summary_default_values() { + let summary = PerformanceSummary { + total_executions: 0, + avg_gas_used: 0.0, + max_gas_used: 0.0, + min_gas_used: 0.0, + avg_execution_time_ms: 0.0, + success_rate: 100.0, + }; + assert_eq!(summary.total_executions, 0); + assert_eq!(summary.success_rate, 100.0); + } +} diff --git a/src/utils/template_vcs.rs b/src/utils/template_vcs.rs new file mode 100644 index 00000000..1306f1ff --- /dev/null +++ b/src/utils/template_vcs.rs @@ -0,0 +1,501 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemplateVersion { + pub version: String, + pub tag: String, + pub message: String, + pub author: String, + pub timestamp: String, + pub changes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemplateChangelog { + pub template_name: String, + pub versions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TemplateBranch { + pub name: String, + pub current: bool, + pub last_commit: String, + pub last_message: String, +} + +fn vcs_dir(template_path: &Path) -> PathBuf { + template_path.join(".starforge-vcs") +} + +fn versions_file(template_path: &Path) -> PathBuf { + vcs_dir(template_path).join("versions.json") +} + +fn changelog_file(template_path: &Path) -> PathBuf { + vcs_dir(template_path).join("CHANGELOG.md") +} + +fn is_git_repo(path: &Path) -> bool { + path.join(".git").exists() +} + +pub fn init_vcs(template_path: &Path, template_name: &str) -> Result<()> { + let vcs = vcs_dir(template_path); + if vcs.exists() { + anyhow::bail!( + "VCS already initialized for '{}'. Use `starforge template vcs status` to check.", + template_name + ); + } + + fs::create_dir_all(&vcs)?; + + let versions = TemplateChangelog { + template_name: template_name.to_string(), + versions: Vec::new(), + }; + fs::write( + versions_file(template_path), + serde_json::to_string_pretty(&versions)?, + )?; + + if !is_git_repo(template_path) { + let output = Command::new("git") + .arg("init") + .arg(template_path) + .output() + .context("Failed to initialize git repo. Is git installed?")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git init failed: {}", stderr); + } + } + + Ok(()) +} + +pub fn commit_version( + template_path: &Path, + version: &str, + message: &str, + author: &str, +) -> Result { + let mut versions = load_versions(template_path)?; + + let tag = format!("v{}", version); + + if versions.versions.iter().any(|v| v.version == version) { + anyhow::bail!("Version '{}' already exists. Bump the version number.", version); + } + + let all_changes: Vec = message + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + + let entry = TemplateVersion { + version: version.to_string(), + tag: tag.clone(), + message: message.to_string(), + author: author.to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + changes: all_changes, + }; + + versions.versions.push(entry.clone()); + versions + .versions + .sort_by(|a, b| b.version.cmp(&a.version)); + + fs::write( + versions_file(template_path), + serde_json::to_string_pretty(&versions)?, + )?; + + if is_git_repo(template_path) { + let output = Command::new("git") + .current_dir(template_path) + .args(["add", "-A"]) + .output() + .context("Failed to stage files")?; + + if !output.status.success() { + anyhow::bail!( + "git add failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let commit_msg = format!("{}: {}", tag, message.lines().next().unwrap_or(message)); + let output = Command::new("git") + .current_dir(template_path) + .args(["commit", "-m", &commit_msg]) + .output() + .context("Failed to commit")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("nothing to commit") { + anyhow::bail!("git commit failed: {}", stderr); + } + } + + let output = Command::new("git") + .current_dir(template_path) + .args(["tag", &tag]) + .output() + .context("Failed to create tag")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.contains("already exists") { + anyhow::bail!("git tag failed: {}", stderr); + } + } + } + + update_changelog(template_path, &versions)?; + + Ok(entry) +} + +pub fn list_branches(template_path: &Path) -> Result> { + if !is_git_repo(template_path) { + anyhow::bail!( + "Not a git repository. Run `starforge template vcs init` first." + ); + } + + let output = Command::new("git") + .current_dir(template_path) + .args(["branch", "-v"]) + .output() + .context("Failed to list branches")?; + + if !output.status.success() { + anyhow::bail!( + "git branch failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut branches = Vec::new(); + + for line in stdout.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let (current, name) = if line.starts_with('*') { + (true, line.strip_prefix("* ").unwrap_or(line).trim()) + } else { + (false, line.trim()) + }; + + let parts: Vec<&str> = name.split_whitespace().collect(); + let branch_name = parts.first().unwrap_or(&name).to_string(); + let last_commit = parts.get(1).unwrap_or(&"").to_string(); + let last_message = parts[2..].join(" "); + + branches.push(TemplateBranch { + name: branch_name, + current, + last_commit, + last_message, + }); + } + + Ok(branches) +} + +pub fn create_branch(template_path: &Path, branch_name: &str) -> Result<()> { + if !is_git_repo(template_path) { + anyhow::bail!( + "Not a git repository. Run `starforge template vcs init` first." + ); + } + + let output = Command::new("git") + .current_dir(template_path) + .args(["checkout", "-b", branch_name]) + .output() + .context("Failed to create branch")?; + + if !output.status.success() { + anyhow::bail!( + "git checkout -b failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) +} + +pub fn switch_branch(template_path: &Path, branch_name: &str) -> Result<()> { + if !is_git_repo(template_path) { + anyhow::bail!( + "Not a git repository. Run `starforge template vcs init` first." + ); + } + + let output = Command::new("git") + .current_dir(template_path) + .args(["checkout", branch_name]) + .output() + .context("Failed to switch branch")?; + + if !output.status.success() { + anyhow::bail!( + "git checkout failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) +} + +pub fn view_log(template_path: &Path, limit: usize) -> Result> { + let versions = load_versions(template_path)?; + let mut sorted = versions.versions; + sorted.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + Ok(sorted.into_iter().take(limit).collect()) +} + +pub fn show_diff(template_path: &Path) -> Result { + if !is_git_repo(template_path) { + anyhow::bail!( + "Not a git repository. Run `starforge template vcs init` first." + ); + } + + let output = Command::new("git") + .current_dir(template_path) + .args(["diff", "--stat"]) + .output() + .context("Failed to run git diff")?; + + if !output.status.success() { + anyhow::bail!( + "git diff failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(stdout) +} + +pub fn create_release(template_path: &Path, version: &str, message: &str, author: &str) -> Result { + commit_version(template_path, version, message, author) +} + +pub fn generate_changelog(template_path: &Path) -> Result { + let versions = load_versions(template_path)?; + + let mut output = String::new(); + output.push_str(&format!("# Changelog — {}\n\n", versions.template_name)); + + for version in &versions.versions { + output.push_str(&format!("## {} ({})\n\n", version.tag, &version.timestamp[..10])); + output.push_str(&format!("**Author:** {}\n\n", version.author)); + + if !version.changes.is_empty() { + for change in &version.changes { + output.push_str(&format!("- {}\n", change)); + } + } else { + output.push_str(&format!("- {}\n", version.message)); + } + output.push('\n'); + } + + if versions.versions.is_empty() { + output.push_str("_No versions recorded yet._\n"); + } + + fs::write(changelog_file(template_path), &output)?; + Ok(output) +} + +pub fn get_version_history(template_path: &Path) -> Result { + load_versions(template_path) +} + +pub fn create_release_with_notes( + template_path: &Path, + version: &str, + message: &str, + author: &str, + notes: &str, +) -> Result { + let mut changes: Vec = message + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect(); + + for line in notes.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() { + changes.push(trimmed.to_string()); + } + } + + let combined = if changes.is_empty() { + message.to_string() + } else { + changes.join("\n") + }; + + commit_version(template_path, version, &combined, author) +} + +fn load_versions(template_path: &Path) -> Result { + let vf = versions_file(template_path); + if !vf.exists() { + return Ok(TemplateChangelog { + template_name: template_path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown".to_string()), + versions: Vec::new(), + }); + } + + let content = fs::read_to_string(&vf) + .with_context(|| format!("Failed to read versions file at {}", vf.display()))?; + let versions: TemplateChangelog = + serde_json::from_str(&content).context("Failed to parse versions file")?; + Ok(versions) +} + +fn update_changelog(template_path: &Path, versions: &TemplateChangelog) -> Result<()> { + let mut output = String::new(); + output.push_str(&format!("# Changelog — {}\n\n", versions.template_name)); + + for version in &versions.versions { + output.push_str(&format!( + "## {} ({})\n\n", + version.tag, + &version.timestamp[..10] + )); + output.push_str(&format!("**Author:** {}\n\n", version.author)); + + if !version.changes.is_empty() { + for change in &version.changes { + output.push_str(&format!("- {}\n", change)); + } + } else { + output.push_str(&format!("- {}\n", version.message)); + } + output.push('\n'); + } + + if versions.versions.is_empty() { + output.push_str("_No versions recorded yet._\n"); + } + + fs::write(changelog_file(template_path), &output)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn make_valid_template(dir: &Path) { + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write( + dir.join("Cargo.toml"), + "[package]\nname = \"{{PROJECT_NAME}}\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + fs::write(dir.join("src/lib.rs"), "#![no_std]\n").unwrap(); + fs::write(dir.join("README.md"), "# Template\n").unwrap(); + } + + #[test] + fn init_vcs_creates_directory_and_versions() { + let tmp = tempdir().unwrap(); + make_valid_template(tmp.path()); + init_vcs(tmp.path(), "test-template").unwrap(); + assert!(vcs_dir(tmp.path()).exists()); + assert!(versions_file(tmp.path()).exists()); + } + + #[test] + fn commit_version_adds_entry() { + let tmp = tempdir().unwrap(); + make_valid_template(tmp.path()); + init_vcs(tmp.path(), "test-template").unwrap(); + + let entry = commit_version(tmp.path(), "1.0.0", "Initial release", "Author").unwrap(); + assert_eq!(entry.version, "1.0.0"); + assert_eq!(entry.tag, "v1.0.0"); + + let versions = load_versions(tmp.path()).unwrap(); + assert_eq!(versions.versions.len(), 1); + } + + #[test] + fn commit_version_rejects_duplicate() { + let tmp = tempdir().unwrap(); + make_valid_template(tmp.path()); + init_vcs(tmp.path(), "test-template").unwrap(); + + commit_version(tmp.path(), "1.0.0", "Initial release", "Author").unwrap(); + let result = commit_version(tmp.path(), "1.0.0", "Duplicate", "Author"); + assert!(result.is_err()); + } + + #[test] + fn generate_changelog_empty() { + let tmp = tempdir().unwrap(); + make_valid_template(tmp.path()); + init_vcs(tmp.path(), "test-template").unwrap(); + + let changelog = generate_changelog(tmp.path()).unwrap(); + assert!(changelog.contains("No versions recorded yet")); + } + + #[test] + fn generate_changelog_with_versions() { + let tmp = tempdir().unwrap(); + make_valid_template(tmp.path()); + init_vcs(tmp.path(), "test-template").unwrap(); + + commit_version(tmp.path(), "1.0.0", "Initial", "Alice").unwrap(); + commit_version(tmp.path(), "1.1.0", "New feature", "Bob").unwrap(); + + let changelog = generate_changelog(tmp.path()).unwrap(); + assert!(changelog.contains("v1.0.0")); + assert!(changelog.contains("v1.1.0")); + assert!(changelog.contains("Alice")); + assert!(changelog.contains("Bob")); + } + + #[test] + fn view_log_returns_versions_in_reverse_chronological_order() { + let tmp = tempdir().unwrap(); + make_valid_template(tmp.path()); + init_vcs(tmp.path(), "test-template").unwrap(); + + commit_version(tmp.path(), "1.0.0", "First", "A").unwrap(); + commit_version(tmp.path(), "2.0.0", "Second", "B").unwrap(); + + let log = view_log(tmp.path(), 10).unwrap(); + assert_eq!(log.len(), 2); + assert_eq!(log[0].version, "2.0.0"); + assert_eq!(log[1].version, "1.0.0"); + } +}