From 0d45c031c3b2b5bd4e20ad2ec21fd817baf148e3 Mon Sep 17 00:00:00 2001 From: Jude Date: Tue, 30 Jun 2026 12:02:31 +0100 Subject: [PATCH] feat: add contract coverage analysis --- docs/COMMAND_REFERENCE.md | 16 + src/commands/backup.rs | 5 +- src/commands/bridge.rs | 36 +- src/commands/debug.rs | 48 +- src/commands/deploy.rs | 40 +- src/commands/deployments.rs | 33 +- src/commands/mod.rs | 2 +- src/commands/monitor.rs | 40 +- src/commands/perf.rs | 45 +- src/commands/schedule.rs | 5 +- src/commands/test.rs | 173 +++- src/commands/verify.rs | 5 +- src/commands/wallet.rs | 93 ++- src/utils/backup.rs | 28 +- src/utils/benchmarking.rs | 18 +- src/utils/bridge/routes.rs | 8 +- src/utils/bridge/security.rs | 21 +- src/utils/bridge/state.rs | 31 +- src/utils/call_graph.rs | 49 +- src/utils/contract_testing.rs | 36 +- src/utils/database.rs | 32 +- src/utils/debugger.rs | 16 +- src/utils/deployment_verify.rs | 13 +- src/utils/gas_analyzer.rs | 44 +- src/utils/gas_report.rs | 12 +- src/utils/network_sim.rs | 16 +- src/utils/security/pentest.rs | 8 +- src/utils/security/remediation.rs | 6 +- src/utils/templates.rs | 10 +- src/utils/test_coverage.rs | 996 ++++++++++++++++++++++-- src/utils/test_runner.rs | 19 +- tests/bridge_integration.rs | 15 +- tests/cli_smoke.rs | 32 +- tests/contract_coverage_analysis.rs | 128 +++ tests/contract_framework_integration.rs | 71 +- tests/deployment_verification.rs | 4 +- tests/hardware_wallet_integration.rs | 10 +- tests/network_simulation.rs | 192 +++-- tests/security_audit_integration.rs | 8 +- 39 files changed, 1925 insertions(+), 439 deletions(-) create mode 100644 tests/contract_coverage_analysis.rs diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index d06c586b..babc5b9a 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -115,6 +115,14 @@ starforge contract generate-bindings ./token.wasm --lang rust | `--fixture ` | JSON/TOML contract test suite with fixtures, mocks, and assertions | | `--source ` | Contract source used for generated tests or coverage | | `--coverage` | Include source coverage summary | +| `--coverage-out ` | Write a dedicated coverage report | +| `--coverage-format html\|json\|markdown\|text` | Format for `--coverage-out` | +| `--coverage-goal ` | Minimum overall coverage percentage | +| `--function-coverage-goal ` | Minimum function coverage percentage | +| `--line-coverage-goal ` | Minimum line coverage percentage | +| `--branch-coverage-goal ` | Minimum branch coverage percentage | +| `--coverage-ci` | Fail when configured coverage goals are missed | +| `--coverage-ci-workflow-out ` | Generate a GitHub Actions coverage workflow | | `--report html\|json\|junit` | Write a test report (`junit` is available for fixture suites) | | `--testnet` | Validate Soroban testnet integration for the run | | `--testnet-dry-run` | Validate testnet configuration without probing RPC health | @@ -123,11 +131,19 @@ starforge contract generate-bindings ./token.wasm --lang rust starforge test --wasm ./target/contract.wasm \ --fixture ./contract-tests.json --coverage --source ./src/lib.rs --report html +starforge test --wasm ./target/contract.wasm --source ./src/lib.rs \ + --coverage --coverage-out coverage.html --coverage-format html \ + --coverage-ci --coverage-goal 85 --branch-coverage-goal 70 + +starforge test --wasm ./target/contract.wasm --source ./src/lib.rs \ + --coverage-ci-workflow-out .github/workflows/starforge-coverage.yml + starforge test --wasm ./target/contract.wasm \ --fixture ./contract-tests.toml --testnet --testnet-dry-run ``` Fixture suites support named storage fixtures, mocked contract calls, and assertions such as `state_equals`, `state_exists`, `return_equals`, `event_emitted`, `fee_at_most`, and `mock_called`. +Coverage analysis tracks Soroban contract functions, line spans, branch paths, uncovered functions, threshold goals, and HTML/JSON/Markdown/text reports. --- diff --git a/src/commands/backup.rs b/src/commands/backup.rs index 52838048..4411ea24 100644 --- a/src/commands/backup.rs +++ b/src/commands/backup.rs @@ -423,7 +423,10 @@ async fn handle_contract_state(args: ContractStateArgs) -> Result<()> { p::kv("Contract", &manifest.contract_id); p::kv("Network", &manifest.source_network); p::kv("Latest ledger", &manifest.latest_ledger.to_string()); - p::kv("State entries", &manifest.instance_storage.len().to_string()); + p::kv( + "State entries", + &manifest.instance_storage.len().to_string(), + ); p::kv("Checksum", &manifest.checksum); p::success("Contract state backup created and verified"); Ok(()) diff --git a/src/commands/bridge.rs b/src/commands/bridge.rs index c13b493f..fb6c7661 100644 --- a/src/commands/bridge.rs +++ b/src/commands/bridge.rs @@ -1,7 +1,13 @@ use crate::utils::bridge::{ - load_config, load_transfers, providers::{self, BridgeTransferRequest, TransferStatus}, - record_transfer, routes::RouteRegistry, save_config, security::SecurityVerifier, - state::StateSynchronizer, monitoring::BridgeMonitor, BridgeConfig, BridgeTransferRecord, + load_config, load_transfers, + monitoring::BridgeMonitor, + providers::{self, BridgeTransferRequest, TransferStatus}, + record_transfer, + routes::RouteRegistry, + save_config, + security::SecurityVerifier, + state::StateSynchronizer, + BridgeConfig, BridgeTransferRecord, }; use crate::utils::print as p; use anyhow::Result; @@ -255,7 +261,10 @@ fn handle_status(args: StatusArgs) -> Result<()> { p::kv("Transfer ID", &record.id); p::kv("Status", &status.to_string()); - p::kv("Source", &format!("{} → {}", record.source_network, record.asset)); + p::kv( + "Source", + &format!("{} → {}", record.source_network, record.asset), + ); p::kv("Dest", &record.dest_network); p::kv("Amount", &record.amount.to_string()); if let Some(ref tx) = record.tx_hash_source { @@ -315,10 +324,11 @@ fn handle_routes(args: RoutesArgs) -> Result<()> { fn handle_configure(args: ConfigureArgs) -> Result<()> { let mut config = load_config()?; - if args.show || (!args.enable.is_some() - && args.default_provider.is_none() - && args.max_amount.is_none() - && args.require_proof.is_none()) + if args.show + || (args.enable.is_none() + && args.default_provider.is_none() + && args.max_amount.is_none() + && args.require_proof.is_none()) { p::header("Bridge Configuration"); p::kv("Enabled", &config.enabled.to_string()); @@ -371,7 +381,10 @@ fn handle_sync(args: SyncArgs) -> Result<()> { p::kv("Dest ledger", &args.dest_ledger.to_string()); p::kv("In sync", &sync.is_in_sync(1000).to_string()); p::kv("Pending", &sync.state().pending_transfers.len().to_string()); - p::kv("Completed", &sync.state().completed_transfers.len().to_string()); + p::kv( + "Completed", + &sync.state().completed_transfers.len().to_string(), + ); p::success("State synchronized"); Ok(()) } @@ -431,7 +444,10 @@ fn handle_monitor(args: MonitorArgs) -> Result<()> { } p::header("Bridge Monitoring"); - p::kv("Unacknowledged alerts", &monitor.unacknowledged_count().to_string()); + p::kv( + "Unacknowledged alerts", + &monitor.unacknowledged_count().to_string(), + ); if args.json { println!("{}", serde_json::to_string_pretty(monitor.alerts())?); diff --git a/src/commands/debug.rs b/src/commands/debug.rs index 45fa52a9..79c03468 100644 --- a/src/commands/debug.rs +++ b/src/commands/debug.rs @@ -159,8 +159,7 @@ async fn handle_start(args: StartArgs) -> Result<()> { } sess.set_variables(vars); - let mut frames = Vec::new(); - frames.push(debugger::StackFrame { + let frames = vec![debugger::StackFrame { function: "contract_init".to_string(), contract_id: Some(cid.clone()), source_location: None, @@ -173,7 +172,7 @@ async fn handle_start(args: StartArgs) -> Result<()> { value: e.value.clone(), }) .collect(), - }); + }]; sess.set_call_stack(frames); sess.add_step_history("Debug session started".to_string()); } @@ -283,21 +282,33 @@ async fn handle_step(args: StepArgs) -> Result<()> { debugger.step_out(); p::success("Stepping out of current function…"); } - _ => anyhow::bail!("Invalid step direction '{}'. Use 'into', 'over', or 'out'.", args.direction), + _ => anyhow::bail!( + "Invalid step direction '{}'. Use 'into', 'over', or 'out'.", + args.direction + ), } - debugger.session.add_step_history(format!("step {}", args.direction)); + debugger + .session + .add_step_history(format!("step {}", args.direction)); - simulate_step(debugger).await?; + simulate_step(debugger)?; Ok(()) } -async fn simulate_step(debugger: &mut Debugger) -> Result<()> { - let current_fn = debugger.session.current_function.clone().unwrap_or_default(); +fn simulate_step(debugger: &mut Debugger) -> Result<()> { + let current_fn = debugger + .session + .current_function + .clone() + .unwrap_or_default(); p::separator(); - p::kv_accent("Current Depth", &debugger.session.call_stack.len().to_string()); + p::kv_accent( + "Current Depth", + &debugger.session.call_stack.len().to_string(), + ); p::kv_accent("Step Count", &debugger.session.step_count.to_string()); if !current_fn.is_empty() { @@ -449,7 +460,11 @@ async fn handle_stack() -> Result<()> { if frames.is_empty() { p::info("Call stack is empty."); } else { - p::header(&format!("Call Stack ({} frame{})", frames.len(), if frames.len() == 1 { "" } else { "s" })); + p::header(&format!( + "Call Stack ({} frame{})", + frames.len(), + if frames.len() == 1 { "" } else { "s" } + )); p::separator(); for (i, frame) in frames.iter().enumerate() { let depth = frames.len() - i; @@ -587,7 +602,12 @@ async fn handle_ui(args: UiArgs) -> Result<()> { async fn manage_breakpoints_interactive() -> Result<()> { let selection = Select::new() .with_prompt("Breakpoint Action") - .items(&["List Breakpoints", "Add Breakpoint", "Remove Breakpoint", "Go Back"]) + .items(&[ + "List Breakpoints", + "Add Breakpoint", + "Remove Breakpoint", + "Go Back", + ]) .default(0) .interact()?; @@ -596,9 +616,7 @@ async fn manage_breakpoints_interactive() -> Result<()> { handle_breakpoint(BreakpointCommands::List).await?; } 1 => { - let function: String = Input::new() - .with_prompt("Function name") - .interact_text()?; + let function: String = Input::new().with_prompt("Function name").interact_text()?; let contract_id: String = Input::new() .with_prompt("Contract ID (optional)") .allow_empty(true) @@ -635,5 +653,3 @@ async fn manage_breakpoints_interactive() -> Result<()> { } Ok(()) } - - diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index 0ec61722..97b32bbb 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -334,7 +334,8 @@ pub async fn handle(args: DeployArgs) -> Result<()> { wasm_size_kb, wallet, &args.network, - ).await; + ) + .await; } if args.simulate { @@ -422,15 +423,17 @@ pub async fn handle(args: DeployArgs) -> Result<()> { let pb = p::progress_bar(3, "Starting deployment steps..."); pb.set_message("Verifying account on-chain..."); - let account = horizon::fetch_account(&wallet.public_key, &args.network).await.map_err(|e| { - pb.abandon(); - anyhow::anyhow!( - "Account not active on {}: {}\nFund it with: starforge wallet fund {}", - args.network, - e, - wallet.name - ) - })?; + let account = horizon::fetch_account(&wallet.public_key, &args.network) + .await + .map_err(|e| { + pb.abandon(); + anyhow::anyhow!( + "Account not active on {}: {}\nFund it with: starforge wallet fund {}", + args.network, + e, + wallet.name + ) + })?; let xlm = account .balances @@ -495,7 +498,12 @@ pub async fn handle(args: DeployArgs) -> Result<()> { p::error(&format!("Stellar CLI deployment failed: {}", stderr)); // Automatic rollback safety net: revert to the last good deployment. - handle_failed_deploy_rollback(args.no_auto_rollback, previous, &wallet.name, &args.network)?; + handle_failed_deploy_rollback( + args.no_auto_rollback, + previous, + &wallet.name, + &args.network, + )?; anyhow::bail!("Stellar CLI deployment failed: {}", stderr); } @@ -567,12 +575,18 @@ mod tests { let id = format!("C{}", "A".repeat(55)); assert_eq!(id.len(), 56); let stdout = format!("ℹ️ Simulating deploy...\nℹ️ Submitting...\n{}\n", id); - assert_eq!(parse_contract_id_from_stdout(&stdout).as_deref(), Some(id.as_str())); + assert_eq!( + parse_contract_id_from_stdout(&stdout).as_deref(), + Some(id.as_str()) + ); } #[test] fn returns_none_when_no_contract_id_present() { - assert_eq!(parse_contract_id_from_stdout("deploy failed: timeout"), None); + assert_eq!( + parse_contract_id_from_stdout("deploy failed: timeout"), + None + ); // A 56-char wallet public key (G...) must not be mistaken for a contract id. let gkey = format!("G{}", "A".repeat(55)); assert_eq!(gkey.len(), 56); diff --git a/src/commands/deployments.rs b/src/commands/deployments.rs index 1606df01..3012f353 100644 --- a/src/commands/deployments.rs +++ b/src/commands/deployments.rs @@ -305,20 +305,24 @@ async fn handle_verify(args: VerifyArgs) -> Result<()> { if let Some(wallet) = cfg.wallets.iter().find(|w| w.name == record.wallet) { match horizon::fetch_account(&wallet.public_key, &record.network).await { Ok(_) => { - report.checks.push(crate::utils::deployment_verify::VerificationCheck { - name: "wallet_active".to_string(), - category: "functionality".to_string(), - status: crate::utils::deployment_verify::CheckStatus::Passed, - detail: format!("Wallet '{}' is active on-chain", record.wallet), - }); + report + .checks + .push(crate::utils::deployment_verify::VerificationCheck { + name: "wallet_active".to_string(), + category: "functionality".to_string(), + status: crate::utils::deployment_verify::CheckStatus::Passed, + detail: format!("Wallet '{}' is active on-chain", record.wallet), + }); } Err(e) => { - report.checks.push(crate::utils::deployment_verify::VerificationCheck { - name: "wallet_active".to_string(), - category: "functionality".to_string(), - status: crate::utils::deployment_verify::CheckStatus::Warning, - detail: format!("Could not verify wallet: {}", e), - }); + report + .checks + .push(crate::utils::deployment_verify::VerificationCheck { + name: "wallet_active".to_string(), + category: "functionality".to_string(), + status: crate::utils::deployment_verify::CheckStatus::Warning, + detail: format!("Could not verify wallet: {}", e), + }); } } } @@ -349,7 +353,10 @@ async fn handle_verify(args: VerifyArgs) -> Result<()> { .iter() .filter(|c| c.status == crate::utils::deployment_verify::CheckStatus::Passed) .count(); - p::kv("Checks passed", &format!("{}/{}", passed_count, report.checks.len())); + p::kv( + "Checks passed", + &format!("{}/{}", passed_count, report.checks.len()), + ); } if args.report || args.save { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 5cbb9551..05d3ede8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,8 +1,8 @@ pub mod analytics; pub mod audit; pub mod backup; -pub mod bridge; pub mod benchmark; +pub mod bridge; pub mod command_tree; pub mod completions; pub mod config; diff --git a/src/commands/monitor.rs b/src/commands/monitor.rs index 47f63fc7..4789f292 100644 --- a/src/commands/monitor.rs +++ b/src/commands/monitor.rs @@ -68,23 +68,29 @@ pub async fn handle(args: MonitorArgs) -> Result<()> { println!(); match (&args.contract, &args.wallet) { - (Some(contract_id), None) => monitor_contract( - contract_id, - args.events.as_deref(), - args.event_type.as_deref(), - args.topic.as_deref(), - args.value.as_deref(), - network, - args.interval, - args.follow, - ).await, - (None, Some(wallet_name)) => monitor_wallet( - wallet_name, - args.threshold, - args.balance_alert, - network, - args.interval, - ).await, + (Some(contract_id), None) => { + monitor_contract( + contract_id, + args.events.as_deref(), + args.event_type.as_deref(), + args.topic.as_deref(), + args.value.as_deref(), + network, + args.interval, + args.follow, + ) + .await + } + (None, Some(wallet_name)) => { + monitor_wallet( + wallet_name, + args.threshold, + args.balance_alert, + network, + args.interval, + ) + .await + } _ => anyhow::bail!("Specify either --contract or --wallet (but not both)"), } } diff --git a/src/commands/perf.rs b/src/commands/perf.rs index cc80bdda..79692d4d 100644 --- a/src/commands/perf.rs +++ b/src/commands/perf.rs @@ -139,7 +139,10 @@ pub async fn handle(cmd: PerfCommands) -> Result<()> { PerfCommands::Bottleneck { contract, network } => bottleneck(contract, network), PerfCommands::Optimize { contract, network } => optimize(contract, network), PerfCommands::Cache { contract, enable } => cache(contract, enable), - PerfCommands::Benchmark { contract, iterations } => benchmark(contract, iterations), + PerfCommands::Benchmark { + contract, + iterations, + } => benchmark(contract, iterations), } } @@ -418,8 +421,11 @@ fn bottleneck(contract: String, _network: String) -> Result<()> { return Ok(()); } - let avg_gas: f64 = gas_history.iter().map(|r| r.gas_used as f64).sum::() / gas_history.len() as f64; - let max_record = gas_history.iter().max_by(|a, b| a.gas_used.cmp(&b.gas_used)); + let avg_gas: f64 = + gas_history.iter().map(|r| r.gas_used as f64).sum::() / gas_history.len() as f64; + let max_record = gas_history + .iter() + .max_by(|a, b| a.gas_used.cmp(&b.gas_used)); p::separator(); p::info("Bottleneck Analysis"); @@ -461,19 +467,27 @@ fn optimize(contract: String, _network: String) -> Result<()> { let mut suggestions = Vec::new(); - let success_rate = 1.0 - (gas_history.iter().filter(|r| !r.success).count() as f64 / gas_history.len() as f64); + let success_rate = + 1.0 - (gas_history.iter().filter(|r| !r.success).count() as f64 / gas_history.len() as f64); if success_rate < 0.95 { suggestions.push("High failure rate detected. Review contract logic and error handling."); } - let avg_time: f64 = gas_history.iter().map(|r| r.execution_time_ms as f64).sum::() / gas_history.len() as f64; + let avg_time: f64 = gas_history + .iter() + .map(|r| r.execution_time_ms as f64) + .sum::() + / gas_history.len() as f64; if avg_time > 5000.0 { - suggestions.push("Execution time exceeds 5 seconds. Consider breaking into smaller operations."); + suggestions + .push("Execution time exceeds 5 seconds. Consider breaking into smaller operations."); } - let avg_gas: f64 = gas_history.iter().map(|r| r.gas_used as f64).sum::() / gas_history.len() as f64; + let avg_gas: f64 = + gas_history.iter().map(|r| r.gas_used as f64).sum::() / gas_history.len() as f64; if avg_gas > 100_000.0 { - suggestions.push("Gas usage is high. Profile critical functions and optimize storage access."); + suggestions + .push("Gas usage is high. Profile critical functions and optimize storage access."); } if suggestions.is_empty() { @@ -546,9 +560,18 @@ fn benchmark(contract: String, iterations: u32) -> Result<()> { println!(); p::info("Summary"); - p::kv("Avg Gas", &format!("{:.0}", total_gas as f64 / iterations as f64)); - p::kv("Avg Time", &format!("{:.0}ms", total_time as f64 / iterations as f64)); - p::kv("Success Rate", &format!("{:.1}%", (successes as f64 / iterations as f64) * 100.0)); + p::kv( + "Avg Gas", + &format!("{:.0}", total_gas as f64 / iterations as f64), + ); + p::kv( + "Avg Time", + &format!("{:.0}ms", total_time as f64 / iterations as f64), + ); + p::kv( + "Success Rate", + &format!("{:.1}%", (successes as f64 / iterations as f64) * 100.0), + ); p::separator(); Ok(()) diff --git a/src/commands/schedule.rs b/src/commands/schedule.rs index 5ef9d994..e9efa5a6 100644 --- a/src/commands/schedule.rs +++ b/src/commands/schedule.rs @@ -267,7 +267,10 @@ fn handle_run(args: RunArgs) -> Result<()> { } } - p::success(&format!("Executed {} scheduled deployment(s)", executed.len())); + p::success(&format!( + "Executed {} scheduled deployment(s)", + executed.len() + )); Ok(()) } diff --git a/src/commands/test.rs b/src/commands/test.rs index bbebf35c..1a953038 100644 --- a/src/commands/test.rs +++ b/src/commands/test.rs @@ -1,4 +1,6 @@ -use crate::utils::{config, contract_testing, print as p, test_automation, test_runner}; +use crate::utils::{ + config, contract_testing, print as p, test_automation, test_coverage, test_runner, +}; use anyhow::Result; use clap::Args; use std::path::PathBuf; @@ -21,6 +23,38 @@ pub struct TestArgs { #[arg(long, default_value = "false")] pub coverage: bool, + /// Write a dedicated coverage report to this path + #[arg(long)] + pub coverage_out: Option, + + /// Dedicated coverage report format (html, json, markdown, text) + #[arg(long, default_value = "html")] + pub coverage_format: String, + + /// Minimum overall coverage percentage required by --coverage-ci + #[arg(long)] + pub coverage_goal: Option, + + /// Minimum function coverage percentage required by --coverage-ci + #[arg(long)] + pub function_coverage_goal: Option, + + /// Minimum line coverage percentage required by --coverage-ci + #[arg(long)] + pub line_coverage_goal: Option, + + /// Minimum branch coverage percentage required by --coverage-ci + #[arg(long)] + pub branch_coverage_goal: Option, + + /// Fail the command when configured coverage goals are not met + #[arg(long, default_value = "false")] + pub coverage_ci: bool, + + /// Generate a GitHub Actions workflow for contract coverage checks + #[arg(long)] + pub coverage_ci_workflow_out: Option, + /// Auto-generate test cases from source #[arg(long, default_value = "false")] pub generate: bool, @@ -63,6 +97,12 @@ pub struct TestArgs { } pub async fn handle(args: TestArgs) -> Result<()> { + let coverage_goals = build_coverage_goals(&args)?; + let coverage_requested = args.coverage + || args.coverage_out.is_some() + || args.coverage_ci + || coverage_goals.has_goals(); + config::validate_file_path(&args.wasm, Some("wasm"))?; if let Some(fixture) = &args.fixture { config::validate_file_path(fixture, None)?; @@ -70,16 +110,30 @@ pub async fn handle(args: TestArgs) -> Result<()> { if let Some(contract_id) = &args.contract_id { config::validate_contract_id(contract_id)?; } - if args.coverage && args.source.is_none() { - anyhow::bail!("--coverage requires --source"); + if coverage_requested && args.source.is_none() { + anyhow::bail!("coverage analysis requires --source"); } if args.generate && args.source.is_none() { anyhow::bail!("--generate requires --source"); } + if args.coverage_ci_workflow_out.is_some() && args.source.is_none() { + anyhow::bail!("--coverage-ci-workflow-out requires --source"); + } + + if let Some(workflow_out) = &args.coverage_ci_workflow_out { + let source = args.source.as_ref().expect("source checked above"); + let path = test_coverage::write_coverage_ci_workflow( + workflow_out, + &args.wasm, + source, + &coverage_goals, + )?; + p::kv("Coverage CI workflow", &path.display().to_string()); + } p::header("Contract Test Runner"); p::kv("Wasm", &args.wasm.display().to_string()); - p::kv("Coverage", if args.coverage { "yes" } else { "no" }); + p::kv("Coverage", if coverage_requested { "yes" } else { "no" }); p::kv("Generate", if args.generate { "yes" } else { "no" }); p::kv("Parallel", if args.parallel { "yes" } else { "no" }); if let Some(fixture) = &args.fixture { @@ -102,11 +156,11 @@ pub async fn handle(args: TestArgs) -> Result<()> { } if let Some(fixture) = &args.fixture { - let report = contract_testing::run_contract_framework( + let mut report = contract_testing::run_contract_framework( &args.wasm, fixture, contract_testing::FrameworkRunOptions { - coverage: args.coverage, + coverage: coverage_requested, report_format: args.report.clone(), source: args.source.clone(), testnet: build_testnet_config(&args), @@ -114,11 +168,16 @@ pub async fn handle(args: TestArgs) -> Result<()> { ) .await?; + let coverage_goals_passed = + handle_coverage_outputs(report.coverage.as_mut(), &args, &coverage_goals)?; print_framework_report(&report); if report.failures > 0 { anyhow::bail!("Some contract framework tests failed"); } + if args.coverage_ci && !coverage_goals_passed { + anyhow::bail!("Coverage goals were not met"); + } p::success("All contract framework tests passed"); return Ok(()); @@ -213,10 +272,10 @@ pub async fn handle(args: TestArgs) -> Result<()> { } // Fall back to original test runner - let result = test_runner::run_contract_tests( + let mut result = test_runner::run_contract_tests( &args.wasm, test_runner::TestOptions { - coverage: args.coverage, + coverage: coverage_requested, report_format: args.report.clone(), parallel: args.parallel, generate: args.generate, @@ -224,6 +283,8 @@ pub async fn handle(args: TestArgs) -> Result<()> { workers: args.workers, }, )?; + let coverage_goals_passed = + handle_coverage_outputs(result.coverage.as_mut(), &args, &coverage_goals)?; println!(); p::separator(); @@ -234,7 +295,7 @@ pub async fn handle(args: TestArgs) -> Result<()> { p::kv("Generated cases", &result.generated_cases.len().to_string()); if let Some(cov) = &result.coverage { - p::kv("Coverage", &format!("{:.1}%", cov.coverage_percent)); + print_coverage_summary(cov); } if let Some(path) = &result.report_path { p::kv("Report path", &path.display().to_string()); @@ -271,11 +332,103 @@ pub async fn handle(args: TestArgs) -> Result<()> { if result.failures > 0 { anyhow::bail!("Some contract tests failed"); } + if args.coverage_ci && !coverage_goals_passed { + anyhow::bail!("Coverage goals were not met"); + } p::success("All contract tests passed"); Ok(()) } +fn build_coverage_goals(args: &TestArgs) -> Result { + Ok(test_coverage::CoverageGoals { + min_overall: validate_coverage_goal("--coverage-goal", args.coverage_goal)?, + min_functions: validate_coverage_goal( + "--function-coverage-goal", + args.function_coverage_goal, + )?, + min_lines: validate_coverage_goal("--line-coverage-goal", args.line_coverage_goal)?, + min_branches: validate_coverage_goal("--branch-coverage-goal", args.branch_coverage_goal)?, + }) +} + +fn validate_coverage_goal(name: &str, value: Option) -> Result> { + if let Some(value) = value { + if !(0.0..=100.0).contains(&value) { + anyhow::bail!("{} must be between 0 and 100", name); + } + } + Ok(value) +} + +fn handle_coverage_outputs( + coverage: Option<&mut test_coverage::CoverageReport>, + args: &TestArgs, + goals: &test_coverage::CoverageGoals, +) -> Result { + let Some(coverage) = coverage else { + if args.coverage_out.is_some() || args.coverage_ci || goals.has_goals() { + anyhow::bail!("Coverage was requested, but no coverage data was produced"); + } + return Ok(true); + }; + + let mut goals_passed = true; + if goals.has_goals() { + let goal_result = test_coverage::apply_coverage_goals(coverage, goals.clone()); + goals_passed = goal_result.passed; + p::kv( + "Coverage goals", + if goal_result.passed { + "passed" + } else { + "failed" + }, + ); + for violation in &goal_result.violations { + p::warn(violation); + } + } + + if let Some(output) = &args.coverage_out { + let path = test_coverage::write_coverage_report(coverage, &args.coverage_format, output)?; + p::kv("Coverage report", &path.display().to_string()); + } + + Ok(goals_passed) +} + +fn print_coverage_summary(cov: &test_coverage::CoverageReport) { + p::kv("Coverage", &format!("{:.1}%", cov.coverage_percent)); + p::kv( + "Function coverage", + &format!( + "{:.1}% ({}/{})", + cov.function_coverage_percent, cov.functions_covered, cov.functions_total + ), + ); + p::kv( + "Line coverage", + &format!( + "{:.1}% ({}/{})", + cov.line_coverage_percent, cov.lines_covered, cov.lines_total + ), + ); + p::kv( + "Branch coverage", + &format!( + "{:.1}% ({}/{})", + cov.branch_coverage_percent, cov.branches_covered, cov.branches_total + ), + ); + if !cov.uncovered_functions.is_empty() { + p::warn(&format!( + "Uncovered functions: {}", + cov.uncovered_functions.join(", ") + )); + } +} + fn build_testnet_config(args: &TestArgs) -> Option { args.testnet .then(|| contract_testing::TestnetIntegrationConfig { @@ -298,7 +451,7 @@ fn print_framework_report(report: &contract_testing::ContractFrameworkReport) { p::kv("Failures", &report.failures.to_string()); if let Some(coverage) = &report.coverage { - p::kv("Coverage", &format!("{:.1}%", coverage.coverage_percent)); + print_coverage_summary(coverage); } if let Some(path) = &report.report_path { p::kv("Report path", &path.display().to_string()); diff --git a/src/commands/verify.rs b/src/commands/verify.rs index 3453872b..77e61bba 100644 --- a/src/commands/verify.rs +++ b/src/commands/verify.rs @@ -928,7 +928,10 @@ fn handle_visualize(args: VisualizeArgs) -> Result<()> { p::kv("Contract", &report.contract); p::kv("Report ID", &report.id); - p::kv("WASM hash", &report.wasm_hash[..16.min(report.wasm_hash.len())]); + p::kv( + "WASM hash", + &report.wasm_hash[..16.min(report.wasm_hash.len())], + ); println!(); println!(" {}", "Property Results".bright_white().bold()); bar(report.proven, "Proven", |s| s.green()); diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index a4269c97..78a8d461 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -350,19 +350,22 @@ pub async fn handle(cmd: WalletCommands) -> Result<()> { mem, iterations, parallelism, - } => create( - name, - fund, - network, - encrypt, - strict, - use_mnemonic, - words, - account_index, - mem, - iterations, - parallelism, - ).await, + } => { + create( + name, + fund, + network, + encrypt, + strict, + use_mnemonic, + words, + account_index, + mem, + iterations, + parallelism, + ) + .await + } WalletCommands::List => list(), WalletCommands::Show { name, reveal } => show(name, reveal).await, WalletCommands::Fund { name } => fund_wallet(name).await, @@ -385,17 +388,20 @@ pub async fn handle(cmd: WalletCommands) -> Result<()> { iterations, parallelism, backup, - } => rotate_wallet( - name, - fund, - network, - encrypt, - strict, - mem, - iterations, - parallelism, - backup, - ).await, + } => { + rotate_wallet( + name, + fund, + network, + encrypt, + strict, + mem, + iterations, + parallelism, + backup, + ) + .await + } WalletCommands::Export { name, all, @@ -974,13 +980,15 @@ async fn merge_wallet( p::separator(); println!(); p::step(1, 3, "Fetching source account…"); - let source_account = horizon::fetch_account(&wallet.public_key, &network).await.map_err(|e| { - anyhow::anyhow!( - "Source account not found on {}: {}\nIt may already be merged or never funded.", - network, - e - ) - })?; + let source_account = horizon::fetch_account(&wallet.public_key, &network) + .await + .map_err(|e| { + anyhow::anyhow!( + "Source account not found on {}: {}\nIt may already be merged or never funded.", + network, + e + ) + })?; validate_account_mergeable(&source_account)?; let xlm_balance = native_xlm_balance(&source_account); @@ -994,13 +1002,15 @@ async fn merge_wallet( } p::step(2, 3, "Validating destination account…"); - horizon::fetch_account(&destination, &network).await.map_err(|_| { - anyhow::anyhow!( - "Destination account does not exist on {}. \ + horizon::fetch_account(&destination, &network) + .await + .map_err(|_| { + anyhow::anyhow!( + "Destination account does not exist on {}. \ The destination must be funded before it can receive a merge.", - network - ) - })?; + network + ) + })?; p::kv("Destination", "✓ Account exists"); p::step(3, 3, "Building account merge transaction…"); @@ -1061,7 +1071,8 @@ async fn merge_wallet( let secret_key = wallet_secret_key(wallet)?; p::info("Submitting account merge…"); let submit_result = - horizon::submit_payment_transaction(&tx_result.transaction_xdr, &secret_key, &network).await?; + horizon::submit_payment_transaction(&tx_result.transaction_xdr, &secret_key, &network) + .await?; println!(); p::separator(); @@ -2166,7 +2177,11 @@ fn multisig_show(name: String) -> Result<()> { Ok(()) } -async fn multisig_submit(name: String, transaction: PathBuf, network: Option) -> Result<()> { +async fn multisig_submit( + name: String, + transaction: PathBuf, + network: Option, +) -> Result<()> { config::validate_wallet_name(&name)?; config::validate_file_path(&transaction, Some("json"))?; diff --git a/src/utils/backup.rs b/src/utils/backup.rs index de6c96ca..6a260cb2 100644 --- a/src/utils/backup.rs +++ b/src/utils/backup.rs @@ -9,8 +9,8 @@ use zip::write::FileOptions; use zip::ZipWriter; use crate::utils::config; -use crate::utils::soroban::ContractInspectResult; use crate::utils::crypto::{decrypt_secret, encrypt_secret}; +use crate::utils::soroban::ContractInspectResult; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum BackupStatus { @@ -184,7 +184,9 @@ fn add_path_to_zip( add_path_to_zip(zip, base, &entry.path(), options)?; } } else { - let rel = path.strip_prefix(base.parent().unwrap_or(base)).unwrap_or(path); + let rel = path + .strip_prefix(base.parent().unwrap_or(base)) + .unwrap_or(path); zip.start_file(rel.to_string_lossy(), options)?; let mut f = fs::File::open(path)?; let mut contents = Vec::new(); @@ -211,7 +213,8 @@ pub fn create_backup( let now = Utc::now().to_rfc3339(); let (filename, write_bytes): (String, Vec) = if encrypt { - let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &archive_bytes); + let b64 = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &archive_bytes); let bundle = encrypt_secret(passphrase.unwrap(), &b64, None)?; (format!("{}.bak.enc", id), bundle.into_bytes()) } else { @@ -260,7 +263,13 @@ pub fn create_contract_state_backup( .join(format!("{}-state-manifest.json", inspect.contract_id)); fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?; - let mut record = create_backup(&[manifest_path.clone()], label, encrypt, passphrase, region)?; + let mut record = create_backup( + std::slice::from_ref(&manifest_path), + label, + encrypt, + passphrase, + region, + )?; let durable_manifest_path = backups_dir()?.join(format!("{}.contract-state.json", record.id)); let mut durable_manifest = manifest; durable_manifest.backup_id = Some(record.id.clone()); @@ -520,7 +529,10 @@ pub fn test_restore(id: &str, passphrase: Option<&str>) -> Result { let extracted = restore_backup(id, tmp.path(), passphrase)?; for path in &extracted { if !Path::new(path).exists() { - anyhow::bail!("Recovery test failed: expected file '{}' missing after restore", path); + anyhow::bail!( + "Recovery test failed: expected file '{}' missing after restore", + path + ); } } Ok(extracted.len()) @@ -631,7 +643,11 @@ pub fn run_automation(passphrase: Option<&str>) -> Result> { let due = match &cfg.last_run { None => true, Some(last) => DateTime::parse_from_rfc3339(last) - .map(|dt| now.signed_duration_since(dt.with_timezone(&Utc)).num_hours() as u64 >= cfg.interval_hours) + .map(|dt| { + now.signed_duration_since(dt.with_timezone(&Utc)) + .num_hours() as u64 + >= cfg.interval_hours + }) .unwrap_or(true), }; if !due { diff --git a/src/utils/benchmarking.rs b/src/utils/benchmarking.rs index 1ebbed7c..91f0d6cf 100644 --- a/src/utils/benchmarking.rs +++ b/src/utils/benchmarking.rs @@ -125,10 +125,16 @@ fn score_lower_is_better(actual: f64, threshold: f64) -> (f64, ComparisonStatus) } else { ComparisonStatus::Meets }; - (100.0_f64.min(80.0 + bonus * 0.2 + (1.0 - ratio) * 20.0), status) + ( + 100.0_f64.min(80.0 + bonus * 0.2 + (1.0 - ratio) * 20.0), + status, + ) } else { let over = (ratio - 1.0).min(2.0); - (((1.0 - over / 2.0) * 79.0).max(0.0), ComparisonStatus::Below) + ( + ((1.0 - over / 2.0) * 79.0).max(0.0), + ComparisonStatus::Below, + ) } } @@ -153,8 +159,7 @@ pub fn run_benchmark(contract_id: &str, network: &str, category: &str) -> Result let report = performance::generate_report(contract_id, network)?; let summary = &report.summary; - let (gas_score, gas_status) = - score_lower_is_better(summary.avg_gas_used, standard.max_avg_gas); + let (gas_score, gas_status) = score_lower_is_better(summary.avg_gas_used, standard.max_avg_gas); let (time_score, time_status) = score_lower_is_better(summary.avg_execution_time_ms, standard.max_avg_execution_ms); let (success_score, success_status) = @@ -274,7 +279,10 @@ mod tests { fn lower_is_better_rewards_under_threshold() { let (score, status) = score_lower_is_better(500.0, 1000.0); assert!(score > 80.0); - assert!(matches!(status, ComparisonStatus::Better | ComparisonStatus::Meets)); + assert!(matches!( + status, + ComparisonStatus::Better | ComparisonStatus::Meets + )); } #[test] diff --git a/src/utils/bridge/routes.rs b/src/utils/bridge/routes.rs index 12c99502..e5ded2dd 100644 --- a/src/utils/bridge/routes.rs +++ b/src/utils/bridge/routes.rs @@ -31,12 +31,7 @@ impl RouteRegistry { &self.routes } - pub fn find( - &self, - source: &str, - dest: &str, - asset: Option<&str>, - ) -> Vec<&BridgeRoute> { + pub fn find(&self, source: &str, dest: &str, asset: Option<&str>) -> Vec<&BridgeRoute> { self.routes .iter() .filter(|r| { @@ -56,7 +51,6 @@ impl RouteRegistry { self.find(source, dest, Some(asset)) .into_iter() .min_by_key(|r| r.fee_bps) - .copied() } } diff --git a/src/utils/bridge/security.rs b/src/utils/bridge/security.rs index 75cfa49a..afca00f9 100644 --- a/src/utils/bridge/security.rs +++ b/src/utils/bridge/security.rs @@ -1,5 +1,5 @@ -use super::BridgeConfig; use super::providers::BridgeTransferRequest; +use super::BridgeConfig; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -46,13 +46,13 @@ impl SecurityVerifier { } pub fn verify_transfer(&self, request: &BridgeTransferRequest) -> SecurityReport { - let mut checks = Vec::new(); - - checks.push(self.check_source_network(&request.source_network)); - checks.push(self.check_dest_network(&request.dest_network)); - checks.push(self.check_amount(request.amount)); - checks.push(self.check_recipient_format(&request.recipient, &request.dest_network)); - checks.push(self.check_asset(&request.asset)); + let checks = vec![ + self.check_source_network(&request.source_network), + self.check_dest_network(&request.dest_network), + self.check_amount(request.amount), + self.check_recipient_format(&request.recipient, &request.dest_network), + self.check_asset(&request.asset), + ]; let passed = checks.iter().all(|c| c.result != SecurityCheck::Failed); @@ -164,10 +164,7 @@ impl SecurityVerifier { SecurityCheckResult { name: "recipient_format".to_string(), result: SecurityCheck::Failed, - detail: format!( - "Invalid recipient format for network '{}'", - dest_network - ), + detail: format!("Invalid recipient format for network '{}'", dest_network), } } } diff --git a/src/utils/bridge/state.rs b/src/utils/bridge/state.rs index 276f2d6a..64f51ec7 100644 --- a/src/utils/bridge/state.rs +++ b/src/utils/bridge/state.rs @@ -54,19 +54,23 @@ impl StateSynchronizer { } pub fn mark_pending(&mut self, transfer_id: &str) { - if !self.state.pending_transfers.contains(&transfer_id.to_string()) { + if !self + .state + .pending_transfers + .contains(&transfer_id.to_string()) + { self.state.pending_transfers.push(transfer_id.to_string()); } } pub fn mark_completed(&mut self, transfer_id: &str) { - self.state - .pending_transfers - .retain(|id| id != transfer_id); - if !self.state.completed_transfers.contains(&transfer_id.to_string()) { - self.state - .completed_transfers - .push(transfer_id.to_string()); + self.state.pending_transfers.retain(|id| id != transfer_id); + if !self + .state + .completed_transfers + .contains(&transfer_id.to_string()) + { + self.state.completed_transfers.push(transfer_id.to_string()); } } @@ -96,6 +100,12 @@ impl StateSynchronizer { } } +impl Default for StateSynchronizer { + fn default() -> Self { + Self::new() + } +} + fn deterministic_balance(ledger: u32) -> u64 { (ledger as u64).wrapping_mul(1_000_000_000) } @@ -120,6 +130,9 @@ mod tests { assert!(sync.state().pending_transfers.contains(&"tx-1".to_string())); sync.mark_completed("tx-1"); assert!(!sync.state().pending_transfers.contains(&"tx-1".to_string())); - assert!(sync.state().completed_transfers.contains(&"tx-1".to_string())); + assert!(sync + .state() + .completed_transfers + .contains(&"tx-1".to_string())); } } diff --git a/src/utils/call_graph.rs b/src/utils/call_graph.rs index 7581ad64..f2ccd656 100644 --- a/src/utils/call_graph.rs +++ b/src/utils/call_graph.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use colored::Colorize; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; @@ -384,7 +385,10 @@ fn detect_patterns(edges: &[CallEdge], content: &str) -> Vec { // Direct recursion: caller == callee on an internal edge indicates // a function calls itself, which may stack-blow unless bounded. let mut saw_recursion = false; - for edge in edges.iter().filter(|e| e.call_type == CallType::InternalCall) { + for edge in edges + .iter() + .filter(|e| e.call_type == CallType::InternalCall) + { if edge.caller == edge.callee && !saw_recursion { patterns.push(CallPattern { name: "recursive-call-cycle".to_string(), @@ -407,12 +411,7 @@ fn detect_patterns(edges: &[CallEdge], content: &str) -> Vec { .map(|e| (e.caller.as_str(), e.callee.as_str())) .collect(); for (a, b) in internal_pairs.iter() { - if a != b - && internal_pairs - .iter() - .any(|(x, y)| *x == *b && *y == *a) - && !saw_mutual - { + if a != b && internal_pairs.iter().any(|(x, y)| *x == *b && *y == *a) && !saw_mutual { patterns.push(CallPattern { name: "mutual-recursion".to_string(), description: format!( @@ -429,7 +428,10 @@ fn detect_patterns(edges: &[CallEdge], content: &str) -> Vec { // Fan-out heuristic: a single function that calls too many distinct // externals raises the attack surface. let mut out_targets: HashMap<&str, HashSet<&str>> = HashMap::new(); - for edge in edges.iter().filter(|e| e.call_type != CallType::InternalCall) { + for edge in edges + .iter() + .filter(|e| e.call_type != CallType::InternalCall) + { out_targets .entry(edge.caller.as_str()) .or_default() @@ -452,7 +454,10 @@ fn detect_patterns(edges: &[CallEdge], content: &str) -> Vec { // Repeated identical external calls — likely candidates for caching. let mut pair_count: HashMap<(&str, &str), usize> = HashMap::new(); - for edge in edges.iter().filter(|e| e.call_type == CallType::DirectInvoke) { + for edge in edges + .iter() + .filter(|e| e.call_type == CallType::DirectInvoke) + { *pair_count .entry((edge.caller.as_str(), edge.callee.as_str())) .or_insert(0) += 1; @@ -612,15 +617,9 @@ pub fn explore_graph(graph: &CallGraph) -> anyhow::Result<()> { loop { println!(); println!("{}", "═".repeat(64).dimmed()); - println!( - " {} Cross-Contract Call Explorer", - "🛰".bright_cyan() - ); + println!(" {} Cross-Contract Call Explorer", "🛰".bright_cyan()); println!("{}", "═".repeat(64).dimmed()); - println!( - " Root contract: {}", - graph.root.bright_white().bold() - ); + println!(" Root contract: {}", graph.root.bright_white().bold()); println!( " Nodes: {} Edges: {} Patterns: {}", graph.nodes.len(), @@ -806,10 +805,7 @@ fn print_node_details(graph: &CallGraph, node: &CallNode) { .filter(|e| e.caller == node.name) .collect(); - println!( - "\n Outgoing calls ({}):", - outgoing.len() - ); + println!("\n Outgoing calls ({}):", outgoing.len()); if outgoing.is_empty() { println!(" (none)"); } else { @@ -829,10 +825,7 @@ fn print_node_details(graph: &CallGraph, node: &CallNode) { } } - println!( - "\n Incoming calls ({}):", - incoming.len() - ); + println!("\n Incoming calls ({}):", incoming.len()); if incoming.is_empty() { println!(" (none)"); } else { @@ -851,7 +844,6 @@ fn print_node_details(graph: &CallGraph, node: &CallNode) { } } - pub fn render_ascii(graph: &CallGraph) -> String { let mut out = String::new(); out.push_str(&format!("Call Graph: {}\n", graph.root)); @@ -1100,10 +1092,7 @@ mod tests { #[test] fn patterns_flag_recursion() { - let g = mk_graph( - vec![edge("root", "root", CallType::InternalCall)], - vec![], - ); + let g = mk_graph(vec![edge("root", "root", CallType::InternalCall)], vec![]); assert!(g.patterns.iter().any(|p| p.name == "recursive-call-cycle")); } diff --git a/src/utils/contract_testing.rs b/src/utils/contract_testing.rs index 0546256e..d3f448b8 100644 --- a/src/utils/contract_testing.rs +++ b/src/utils/contract_testing.rs @@ -1,4 +1,7 @@ -use crate::utils::{config, mock_soroban, soroban, test_coverage}; +use crate::utils::{ + config, mock_soroban, soroban, + test_coverage::{self, CoverageTestExecution}, +}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -66,20 +69,15 @@ pub struct StateEntry { pub scope: StorageScope, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum StorageScope { + #[default] Instance, Persistent, Temporary, } -impl Default for StorageScope { - fn default() -> Self { - Self::Instance - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MockInvocation { #[serde(default)] @@ -310,9 +308,16 @@ pub async fn run_contract_framework_with_spec( fs::read_to_string(source) .with_context(|| format!("Failed to read {}", source.display())) .map(|content| { - let executed: Vec = - cases.iter().map(|case| case.function.clone()).collect(); - test_coverage::analyze_source_coverage(&content, &executed) + let executions = cases + .iter() + .map(|case| { + CoverageTestExecution::new(case.name.clone(), case.function.clone()) + }) + .collect::>(); + test_coverage::analyze_source_coverage_with_executions( + &content, + &executions, + ) }) }) .transpose()? @@ -771,7 +776,14 @@ fn render_html_report(report: &ContractFrameworkReport) -> String { let coverage = report .coverage .as_ref() - .map(|coverage| format!("

Coverage: {:.1}%

", coverage.coverage_percent)) + .map(|coverage| { + format!( + "

Coverage: {:.1}% | Functions: {:.1}% | Branches: {:.1}%

", + coverage.coverage_percent, + coverage.function_coverage_percent, + coverage.branch_coverage_percent + ) + }) .unwrap_or_default(); let testnet = report diff --git a/src/utils/database.rs b/src/utils/database.rs index 895413b6..f9cf809c 100644 --- a/src/utils/database.rs +++ b/src/utils/database.rs @@ -341,7 +341,11 @@ impl Database { rows.map(|r| r.map_err(anyhow::Error::from)).collect() } - pub fn aggregate_events(&self, bucket: &AggregationBucket, filters: &EventSearchFilters) -> Result> { + pub fn aggregate_events( + &self, + bucket: &AggregationBucket, + filters: &EventSearchFilters, + ) -> Result> { let bucket_sql = match bucket { AggregationBucket::Hour => "strftime('%Y-%m-%d %H:00:00', timestamp) AS bucket", AggregationBucket::Day => "strftime('%Y-%m-%d', timestamp) AS bucket", @@ -402,7 +406,12 @@ impl Database { rows.map(|r| r.map_err(anyhow::Error::from)).collect() } - pub fn export_events(&self, filters: &EventSearchFilters, format: ExportFormat, writer: &mut impl std::io::Write) -> Result<()> { + pub fn export_events( + &self, + filters: &EventSearchFilters, + format: ExportFormat, + writer: &mut impl std::io::Write, + ) -> Result<()> { let events = self.search_events(filters)?; match format { @@ -411,7 +420,16 @@ impl Database { } ExportFormat::Csv => { let mut wtr = csv::Writer::from_writer(writer); - wtr.write_record(&["id", "event_type", "contract_id", "ledger", "topics", "value", "timestamp", "network"])?; + wtr.write_record(&[ + "id", + "event_type", + "contract_id", + "ledger", + "topics", + "value", + "timestamp", + "network", + ])?; for event in events { wtr.write_record(&[ &event.id, @@ -792,7 +810,7 @@ mod tests { network: "testnet".to_string(), }; db.insert_event(&event).unwrap(); - + let filters = EventSearchFilters { contract_id: Some("CABC123".to_string()), ..Default::default() @@ -822,13 +840,15 @@ mod tests { ledger: 2, topics: None, value: "{}".to_string(), - timestamp: "2024-01-01T01:00:00Z".to_string(), + timestamp: "2024-01-01T00:30:00Z".to_string(), network: "testnet".to_string(), }; db.insert_event(&event1).unwrap(); db.insert_event(&event2).unwrap(); - let aggregates = db.aggregate_events(&AggregationBucket::Hour, &EventSearchFilters::default()).unwrap(); + let aggregates = db + .aggregate_events(&AggregationBucket::Hour, &EventSearchFilters::default()) + .unwrap(); assert_eq!(aggregates.len(), 1); assert_eq!(aggregates[0].count, 2); } diff --git a/src/utils/debugger.rs b/src/utils/debugger.rs index e2cfb551..a2727dce 100644 --- a/src/utils/debugger.rs +++ b/src/utils/debugger.rs @@ -89,7 +89,8 @@ impl DebugSession { pub fn add_step_history(&mut self, entry: String) { self.step_count += 1; - self.history.push(format!("#{}: {}", self.step_count, entry)); + self.history + .push(format!("#{}: {}", self.step_count, entry)); } pub fn set_variables(&mut self, vars: Vec) { @@ -180,7 +181,7 @@ impl Debugger { let contract_matches = self.session.breakpoints[i] .contract_id .as_ref() - .map_or(true, |cid| cid == contract_id); + .is_none_or(|cid| cid == contract_id); if contract_matches && self.session.breakpoints[i].function == function { if let Some(ref condition) = self.session.breakpoints[i].condition { if !evaluate_condition(condition) { @@ -252,13 +253,12 @@ impl Debugger { .collect(); for frame in &self.session.call_stack { for v in &frame.variables { - if !results.iter().any(|r| std::ptr::eq(*r, v)) { - if v.name.to_lowercase().contains(&lower) + if !results.iter().any(|r| std::ptr::eq(*r, v)) + && (v.name.to_lowercase().contains(&lower) || v.value.to_lowercase().contains(&lower) - || v.var_type.to_lowercase().contains(&lower) - { - results.push(v); - } + || v.var_type.to_lowercase().contains(&lower)) + { + results.push(v); } } } diff --git a/src/utils/deployment_verify.rs b/src/utils/deployment_verify.rs index e8cc7dd6..4d4778ac 100644 --- a/src/utils/deployment_verify.rs +++ b/src/utils/deployment_verify.rs @@ -140,8 +140,8 @@ impl DeploymentVerifier { }); let local_hash = hex::encode(Sha256::digest(bytes)); - let hash_match = local_hash == self.record.wasm_hash - || self.record.wasm_hash.is_empty(); + let hash_match = + local_hash == self.record.wasm_hash || self.record.wasm_hash.is_empty(); checks.push(VerificationCheck { name: "local_wasm_hash".to_string(), category: "bytecode".to_string(), @@ -160,10 +160,7 @@ impl DeploymentVerifier { name: "wasm_format".to_string(), category: "bytecode".to_string(), status: CheckStatus::Skipped, - detail: format!( - "WASM file not found at {}", - self.record.wasm_path - ), + detail: format!("WASM file not found at {}", self.record.wasm_path), }); } @@ -226,8 +223,8 @@ impl DeploymentVerifier { fn check_bytecode_hash_match(&self, inspect: &ContractInspectResult) -> VerificationCheck { match &inspect.wasm_hash { Some(onchain) => { - let matches = onchain == &self.record.wasm_hash - || onchain == "mock_wasm_hash_placeholder"; + let matches = + onchain == &self.record.wasm_hash || onchain == "mock_wasm_hash_placeholder"; VerificationCheck { name: "bytecode_hash_match".to_string(), category: "bytecode".to_string(), diff --git a/src/utils/gas_analyzer.rs b/src/utils/gas_analyzer.rs index 7ed4263a..4bcefc05 100644 --- a/src/utils/gas_analyzer.rs +++ b/src/utils/gas_analyzer.rs @@ -131,8 +131,8 @@ pub struct GasCostBreakdown { impl GasCostBreakdown { pub fn compute(profile: &WasmSectionProfile, size_bytes: usize) -> Self { let upload_cost = (size_bytes as u64).saturating_mul(FEE_PER_CODE_BYTE); - let cpu_cost = (profile.estimated_instruction_count as u64) - .saturating_mul(CPU_PER_INSTRUCTION); + let cpu_cost = + (profile.estimated_instruction_count as u64).saturating_mul(CPU_PER_INSTRUCTION); let import_cost = (profile.import_count as u64).saturating_mul(FEE_PER_IMPORT); let export_cost = (profile.export_count as u64).saturating_mul(FEE_PER_EXPORT); let global_cost = (profile.global_count as u64).saturating_mul(FEE_PER_GLOBAL); @@ -409,8 +409,7 @@ pub fn generate_findings(bytes: &[u8], profile: &WasmSectionProfile) -> Vec Vec Vec Vec Vec { /// Perform a full gas analysis on a WASM file. pub fn analyze_wasm_file(path: &Path, label: Option<&str>) -> Result { - let bytes = fs::read(path) - .with_context(|| format!("Failed to read WASM file: {}", path.display()))?; + let bytes = + fs::read(path).with_context(|| format!("Failed to read WASM file: {}", path.display()))?; if !is_valid_wasm(&bytes) { anyhow::bail!( @@ -690,14 +685,12 @@ pub fn analyze_wasm_file(path: &Path, label: Option<&str>) -> Result Result 0 { - instr_delta as f64 - / base_report.section_profile.estimated_instruction_count as f64 - * 100.0 + instr_delta as f64 / base_report.section_profile.estimated_instruction_count as f64 * 100.0 } else { 0.0 }; @@ -777,8 +768,7 @@ pub fn compare_versions(baseline: &Path, candidate: &Path) -> Result 0 { format!( "Regressed — candidate costs ~{} more gas ({:.1}% increase)", - gas_delta, - gas_delta_pct + gas_delta, gas_delta_pct ) } else { "No change in estimated gas cost".to_string() diff --git a/src/utils/gas_report.rs b/src/utils/gas_report.rs index 5aafdfca..f5ed3f4b 100644 --- a/src/utils/gas_report.rs +++ b/src/utils/gas_report.rs @@ -259,8 +259,14 @@ mod tests { #[test] fn parses_format_strings() { - assert!(matches!(ReportFormat::parse("json").unwrap(), ReportFormat::Json)); - assert!(matches!(ReportFormat::parse("HTML").unwrap(), ReportFormat::Html)); + assert!(matches!( + ReportFormat::parse("json").unwrap(), + ReportFormat::Json + )); + assert!(matches!( + ReportFormat::parse("HTML").unwrap(), + ReportFormat::Html + )); assert!(ReportFormat::parse("bogus").is_err()); } -} \ No newline at end of file +} diff --git a/src/utils/network_sim.rs b/src/utils/network_sim.rs index e42e3328..2476c4db 100644 --- a/src/utils/network_sim.rs +++ b/src/utils/network_sim.rs @@ -178,8 +178,7 @@ impl NetworkSimulator { /// Save current state under `name`. pub fn snapshot(&mut self, name: &str) { - self.snapshots - .insert(name.to_string(), self.state.clone()); + self.snapshots.insert(name.to_string(), self.state.clone()); } /// Restore state from a previously saved snapshot. @@ -215,7 +214,8 @@ impl NetworkSimulator { self.check_failure()?; self.simulate_latency(); - let contract_id = deterministic_contract_id(wasm_hash, self.seed, self.state.ledger_sequence); + let contract_id = + deterministic_contract_id(wasm_hash, self.seed, self.state.ledger_sequence); let contract = SimContract { contract_id: contract_id.clone(), wasm_hash: wasm_hash.to_string(), @@ -236,7 +236,9 @@ impl NetworkSimulator { wasm_hash: wasm_hash.to_string(), storage: HashMap::new(), }; - self.state.contracts.insert(contract_id.to_string(), contract); + self.state + .contracts + .insert(contract_id.to_string(), contract); self.state.ledger_sequence += 1; Ok(()) } @@ -360,8 +362,8 @@ impl NetworkSimulator { Ok(()) } - fn check_failure(&self) -> Result<()> { - match &self.failure_mode { + fn check_failure(&mut self) -> Result<()> { + match self.failure_mode.clone() { FailureMode::None => Ok(()), FailureMode::RpcTimeout => { anyhow::bail!("Simulated RPC timeout (injected failure)") @@ -377,7 +379,7 @@ impl NetworkSimulator { } FailureMode::Random { probability_pct } => { let roll = self.next_random() % 100; - if roll < *probability_pct as u64 { + if roll < probability_pct as u64 { anyhow::bail!( "Simulated random failure ({}% probability, roll={})", probability_pct, diff --git a/src/utils/security/pentest.rs b/src/utils/security/pentest.rs index fdc8d733..8ad69c00 100644 --- a/src/utils/security/pentest.rs +++ b/src/utils/security/pentest.rs @@ -129,7 +129,8 @@ fn check_replay(source: &str) -> Option<(String, String)> { fn check_dos(source: &str) -> Option<(String, String)> { let has_loop = source.contains("for ") || source.contains("while "); - let has_bound = source.contains(".take(") || source.contains("MAX_") || source.contains("limit"); + let has_bound = + source.contains(".take(") || source.contains("MAX_") || source.contains("limit"); if has_loop && !has_bound { Some(( "Loop construct found without an apparent bound or limit constant.".into(), @@ -144,7 +145,10 @@ fn check_panic(source: &str) -> Option<(String, String)> { let count = source.matches(".unwrap()").count() + source.matches(".expect(").count(); if count > 0 { Some(( - format!("{} unwrap()/expect() call(s) found that can panic on unexpected input.", count), + format!( + "{} unwrap()/expect() call(s) found that can panic on unexpected input.", + count + ), "Return `Result`/`Option` and handle errors explicitly instead of panicking.".into(), )) } else { diff --git a/src/utils/security/remediation.rs b/src/utils/security/remediation.rs index f2a2f488..c3187a74 100644 --- a/src/utils/security/remediation.rs +++ b/src/utils/security/remediation.rs @@ -80,9 +80,9 @@ pub fn track_findings( let now = Utc::now().to_rfc3339(); for (title, severity, description, remediation) in findings { - let exists = items - .iter() - .any(|i| i.source == source && &i.title == title && i.status != RemediationStatus::WontFix); + let exists = items.iter().any(|i| { + i.source == source && &i.title == title && i.status != RemediationStatus::WontFix + }); if exists { continue; } diff --git a/src/utils/templates.rs b/src/utils/templates.rs index e11c5199..fefd8f1b 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -793,12 +793,18 @@ pub fn generate_template_docs(entry: &TemplateEntry) -> String { md.push_str("## Overview\n\n"); md.push_str(&format!("- **Version:** {}\n", entry.version)); - md.push_str(&format!("- **Quality score:** {}/100\n", entry.quality_score())); + md.push_str(&format!( + "- **Quality score:** {}/100\n", + entry.quality_score() + )); md.push_str(&format!( "- **Verified:** {}\n", if entry.verified { "yes" } else { "no" } )); - md.push_str(&format!("- **Maintenance:** {}\n", entry.maintenance.label())); + md.push_str(&format!( + "- **Maintenance:** {}\n", + entry.maintenance.label() + )); if !entry.author.is_empty() { md.push_str(&format!("- **Author:** {}\n", entry.author)); } diff --git a/src/utils/test_coverage.rs b/src/utils/test_coverage.rs index 396b3630..fa16225c 100644 --- a/src/utils/test_coverage.rs +++ b/src/utils/test_coverage.rs @@ -1,5 +1,8 @@ +use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CoverageReport { @@ -11,43 +14,233 @@ pub struct CoverageReport { pub branches_covered: u32, pub uncovered_functions: Vec, pub coverage_percent: f64, + pub function_coverage_percent: f64, + pub line_coverage_percent: f64, + pub branch_coverage_percent: f64, + pub functions: Vec, + pub branches: Vec, + pub goals: Option, + pub visualization: CoverageVisualization, + pub generated_at: String, } -pub fn analyze_source_coverage(source: &str, executed_functions: &[String]) -> CoverageReport { - let all_functions: Vec = source - .lines() - .filter_map(|line| { - let trimmed = line.trim(); - if trimmed.starts_with("pub fn ") { - trimmed - .strip_prefix("pub fn ") - .and_then(|rest| rest.split('(').next()) - .map(|s| s.trim().to_string()) - } else { - None - } - }) - .collect(); +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FunctionCoverage { + pub name: String, + pub signature: String, + pub start_line: u32, + pub end_line: u32, + pub lines_total: u32, + pub lines_covered: u32, + pub branches_total: u32, + pub branches_covered: u32, + pub covered: bool, + pub test_cases: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BranchCoverage { + pub id: String, + pub function: String, + pub line: u32, + pub kind: BranchKind, + pub condition: String, + pub paths_total: u32, + pub paths_covered: u32, + pub covered: bool, + pub test_cases: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BranchKind { + If, + ElseIf, + Match, + RequireAuth, + ResultPropagation, + AssertionGuard, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CoverageVisualization { + pub summary_bars: Vec, + pub heatmap: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoverageBar { + pub label: String, + pub percent: f64, + pub covered: u32, + pub total: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoverageHeatmapEntry { + pub function: String, + pub line_start: u32, + pub line_end: u32, + pub intensity: f64, + pub covered: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoverageTestExecution { + pub test_name: String, + pub function: String, + #[serde(default = "default_passed")] + pub passed: bool, +} + +impl CoverageTestExecution { + pub fn new(test_name: impl Into, function: impl Into) -> Self { + Self { + test_name: test_name.into(), + function: function.into(), + passed: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CoverageGoals { + pub min_overall: Option, + pub min_functions: Option, + pub min_lines: Option, + pub min_branches: Option, +} + +impl CoverageGoals { + pub fn has_goals(&self) -> bool { + self.min_overall.is_some() + || self.min_functions.is_some() + || self.min_lines.is_some() + || self.min_branches.is_some() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoverageGoalResult { + pub passed: bool, + pub actual_overall: f64, + pub min_overall: Option, + pub actual_functions: f64, + pub min_functions: Option, + pub actual_lines: f64, + pub min_lines: Option, + pub actual_branches: f64, + pub min_branches: Option, + pub violations: Vec, +} - let executed: HashSet<_> = executed_functions.iter().cloned().collect(); - let uncovered: Vec = all_functions +#[derive(Debug, Clone)] +struct SourceFunction { + name: String, + signature: String, + start_line: usize, + end_line: usize, +} + +pub fn analyze_source_coverage(source: &str, executed_functions: &[String]) -> CoverageReport { + let executions = executed_functions .iter() - .filter(|f| !executed.contains(*f)) - .cloned() - .collect(); + .map(|function| CoverageTestExecution::new(function.clone(), function.clone())) + .collect::>(); + analyze_source_coverage_with_executions(source, &executions) +} - let functions_total = all_functions.len() as u32; - let functions_covered = functions_total - uncovered.len() as u32; - let lines_total = source.lines().count() as u32; - let lines_covered = estimate_lines_covered(source, &executed); - let branches_total = count_branches(source); - let branches_covered = (branches_total as f64 * 0.7) as u32; // heuristic +pub fn analyze_source_coverage_with_executions( + source: &str, + executions: &[CoverageTestExecution], +) -> CoverageReport { + let discovered = discover_functions(source); + let mut tests_by_function: HashMap> = HashMap::new(); - let coverage_percent = if functions_total == 0 { - 100.0 - } else { - (functions_covered as f64 / functions_total as f64) * 100.0 - }; + for execution in executions { + tests_by_function + .entry(execution.function.clone()) + .or_default() + .push(execution.test_name.clone()); + } + + let mut branches = Vec::new(); + for function in &discovered { + branches.extend(discover_branches(source, function, &tests_by_function)); + } + + let mut functions = Vec::new(); + for function in discovered { + let test_cases = tests_by_function + .get(&function.name) + .cloned() + .unwrap_or_default(); + let covered = !test_cases.is_empty(); + let lines_total = count_function_lines(source, function.start_line, function.end_line); + let function_branches = branches + .iter() + .filter(|branch| branch.function == function.name) + .collect::>(); + let branches_total = function_branches + .iter() + .map(|branch| branch.paths_total) + .sum::(); + let branches_covered = function_branches + .iter() + .map(|branch| branch.paths_covered) + .sum::(); + + functions.push(FunctionCoverage { + name: function.name, + signature: function.signature, + start_line: function.start_line as u32, + end_line: function.end_line as u32, + lines_total, + lines_covered: if covered { lines_total } else { 0 }, + branches_total, + branches_covered, + covered, + test_cases, + }); + } + + let functions_total = functions.len() as u32; + let functions_covered = functions.iter().filter(|function| function.covered).count() as u32; + let lines_total = functions + .iter() + .map(|function| function.lines_total) + .sum::(); + let lines_covered = functions + .iter() + .map(|function| function.lines_covered) + .sum::(); + let branches_total = branches + .iter() + .map(|branch| branch.paths_total) + .sum::(); + let branches_covered = branches + .iter() + .map(|branch| branch.paths_covered) + .sum::(); + let uncovered_functions = functions + .iter() + .filter(|function| !function.covered) + .map(|function| function.name.clone()) + .collect::>(); + + let function_coverage_percent = percent(functions_covered, functions_total); + let line_coverage_percent = percent(lines_covered, lines_total); + let branch_coverage_percent = percent(branches_covered, branches_total); + let coverage_percent = weighted_percent( + functions_covered + lines_covered + branches_covered, + functions_total + lines_total + branches_total, + ); + let visualization = build_visualization( + &functions, + function_coverage_percent, + line_coverage_percent, + branch_coverage_percent, + ); CoverageReport { functions_total, @@ -56,37 +249,736 @@ pub fn analyze_source_coverage(source: &str, executed_functions: &[String]) -> C lines_covered, branches_total, branches_covered, - uncovered_functions: uncovered, + uncovered_functions, coverage_percent, + function_coverage_percent, + line_coverage_percent, + branch_coverage_percent, + functions, + branches, + goals: None, + visualization, + generated_at: chrono::Utc::now().to_rfc3339(), + } +} + +pub fn apply_coverage_goals( + report: &mut CoverageReport, + goals: CoverageGoals, +) -> CoverageGoalResult { + let result = evaluate_coverage_goals(report, &goals); + report.goals = Some(result.clone()); + result +} + +pub fn evaluate_coverage_goals( + report: &CoverageReport, + goals: &CoverageGoals, +) -> CoverageGoalResult { + let mut violations = Vec::new(); + + check_goal( + "overall coverage", + report.coverage_percent, + goals.min_overall, + &mut violations, + ); + check_goal( + "function coverage", + report.function_coverage_percent, + goals.min_functions, + &mut violations, + ); + check_goal( + "line coverage", + report.line_coverage_percent, + goals.min_lines, + &mut violations, + ); + check_goal( + "branch coverage", + report.branch_coverage_percent, + goals.min_branches, + &mut violations, + ); + + CoverageGoalResult { + passed: violations.is_empty(), + actual_overall: report.coverage_percent, + min_overall: goals.min_overall, + actual_functions: report.function_coverage_percent, + min_functions: goals.min_functions, + actual_lines: report.line_coverage_percent, + min_lines: goals.min_lines, + actual_branches: report.branch_coverage_percent, + min_branches: goals.min_branches, + violations, + } +} + +pub fn write_coverage_report( + report: &CoverageReport, + format: &str, + output_path: &Path, +) -> Result { + if let Some(parent) = output_path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + } + + let rendered = render_coverage_report(report, format)?; + fs::write(output_path, rendered) + .with_context(|| format!("Failed to write {}", output_path.display()))?; + Ok(output_path.to_path_buf()) +} + +pub fn render_coverage_report(report: &CoverageReport, format: &str) -> Result { + match format { + "json" => Ok(serde_json::to_string_pretty(report)?), + "html" => Ok(render_html_report(report)), + "markdown" | "md" => Ok(render_markdown_report(report)), + "text" | "txt" => Ok(render_text_report(report)), + other => anyhow::bail!( + "Unsupported coverage report format '{}'. Use html, json, markdown, or text.", + other + ), + } +} + +pub fn write_coverage_ci_workflow( + output_path: &Path, + wasm: &Path, + source: &Path, + goals: &CoverageGoals, +) -> Result { + if let Some(parent) = output_path.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + } + + let mut command = format!( + "starforge test --wasm {} --source {} --coverage --coverage-ci", + shell_quote(&portable_path(wasm)), + shell_quote(&portable_path(source)) + ); + + if let Some(value) = goals.min_overall { + command.push_str(&format!(" --coverage-goal {:.1}", value)); + } + if let Some(value) = goals.min_functions { + command.push_str(&format!(" --function-coverage-goal {:.1}", value)); + } + if let Some(value) = goals.min_lines { + command.push_str(&format!(" --line-coverage-goal {:.1}", value)); + } + if let Some(value) = goals.min_branches { + command.push_str(&format!(" --branch-coverage-goal {:.1}", value)); + } + command.push_str(" --coverage-format html --coverage-out coverage/contract-coverage.html"); + + let yaml = format!( + r#"name: StarForge Contract Coverage + +on: + pull_request: + push: + branches: [ master, main ] + +jobs: + contract-coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + - name: Install StarForge + run: cargo install --path . + - name: Run Soroban contract coverage + run: {} + - uses: actions/upload-artifact@v4 + if: always() + with: + name: contract-coverage + path: coverage/contract-coverage.html +"#, + command + ); + + fs::write(output_path, yaml) + .with_context(|| format!("Failed to write {}", output_path.display()))?; + Ok(output_path.to_path_buf()) +} + +fn discover_functions(source: &str) -> Vec { + let lines = source.lines().collect::>(); + let mut functions = Vec::new(); + let mut line_index = 0usize; + + while line_index < lines.len() { + let line = strip_line_comment(lines[line_index]); + if let Some(name) = extract_function_name(line.trim()) { + let (signature, end_line) = collect_signature_and_end(&lines, line_index); + functions.push(SourceFunction { + name, + signature, + start_line: line_index + 1, + end_line, + }); + line_index = end_line; + } else { + line_index += 1; + } + } + + functions +} + +fn collect_signature_and_end(lines: &[&str], start_index: usize) -> (String, usize) { + let mut signature_parts = Vec::new(); + let mut brace_depth = 0i32; + let mut body_started = false; + let mut end_line = start_index + 1; + + for (offset, line) in lines[start_index..].iter().enumerate() { + let cleaned = strip_line_comment(line); + let trimmed = cleaned.trim(); + if !trimmed.is_empty() { + signature_parts.push(trimmed.to_string()); + } + + for ch in cleaned.chars() { + match ch { + '{' => { + body_started = true; + brace_depth += 1; + } + '}' => { + brace_depth -= 1; + } + _ => {} + } + } + + end_line = start_index + offset + 1; + if body_started && brace_depth <= 0 { + break; + } + if !body_started && offset > 0 && extract_function_name(trimmed).is_some() { + end_line = start_index + offset; + break; + } + } + + (signature_parts.join(" "), end_line) +} + +fn discover_branches( + source: &str, + function: &SourceFunction, + tests_by_function: &HashMap>, +) -> Vec { + let lines = source.lines().collect::>(); + let test_cases = tests_by_function + .get(&function.name) + .cloned() + .unwrap_or_default(); + let mut branches = Vec::new(); + + for line_number in function.start_line..=function.end_line { + let Some(raw) = lines.get(line_number - 1) else { + continue; + }; + let cleaned = strip_line_comment(raw); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + continue; + } + + for (kind, condition) in branch_points(trimmed) { + let paths_total = paths_for_branch(kind, &condition); + let paths_covered = estimate_branch_paths(kind, paths_total, &test_cases); + branches.push(BranchCoverage { + id: format!("{}:{}:{:?}", function.name, line_number, kind), + function: function.name.clone(), + line: line_number as u32, + kind, + condition, + paths_total, + paths_covered, + covered: paths_total > 0 && paths_covered >= paths_total, + test_cases: test_cases.clone(), + }); + } + } + + branches +} + +fn branch_points(line: &str) -> Vec<(BranchKind, String)> { + let mut points = Vec::new(); + let normalized = line.trim(); + + if normalized.starts_with("else if ") { + points.push(( + BranchKind::ElseIf, + condition_after_keyword(normalized, "else if"), + )); + } else if normalized.starts_with("if ") || normalized.contains(" if ") { + points.push((BranchKind::If, condition_after_keyword(normalized, "if"))); + } + + if normalized.starts_with("match ") || normalized.contains(" match ") { + points.push(( + BranchKind::Match, + condition_after_keyword(normalized, "match"), + )); + } + + if normalized.contains("require_auth(") || normalized.contains(".require_auth()") { + points.push((BranchKind::RequireAuth, "require_auth".to_string())); + } + + if normalized.contains("assert!") + || normalized.contains("assert_eq!") + || normalized.contains("assert_ne!") + || normalized.contains("panic!") + || normalized.contains("ensure!") + { + points.push((BranchKind::AssertionGuard, normalized.to_string())); + } + + if normalized.contains('?') { + points.push((BranchKind::ResultPropagation, normalized.to_string())); + } + + points +} + +fn condition_after_keyword(line: &str, keyword: &str) -> String { + let Some(index) = line.find(keyword) else { + return line.to_string(); + }; + let rest = line[index + keyword.len()..].trim(); + rest.split('{') + .next() + .unwrap_or(rest) + .trim() + .trim_end_matches("=>") + .trim() + .to_string() +} + +fn paths_for_branch(kind: BranchKind, condition: &str) -> u32 { + match kind { + BranchKind::Match => condition.matches("=>").count().max(2) as u32, + _ => 2, } } -fn estimate_lines_covered(source: &str, executed: &HashSet) -> u32 { - let mut covered = 0u32; - let mut current_fn: Option = None; - for line in source.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("pub fn ") { - current_fn = trimmed - .strip_prefix("pub fn ") - .and_then(|rest| rest.split('(').next()) - .map(|s| s.trim().to_string()); +fn estimate_branch_paths(kind: BranchKind, paths_total: u32, test_cases: &[String]) -> u32 { + if test_cases.is_empty() || paths_total == 0 { + return 0; + } + + let mut signals = HashSet::new(); + for test_name in test_cases { + let lower = test_name.to_ascii_lowercase(); + if has_negative_signal(kind, &lower) { + signals.insert("negative"); } - if current_fn.as_ref().is_some_and(|f| { - executed.contains(f) && !trimmed.is_empty() && !trimmed.starts_with("//") - }) { - covered += 1; + if has_positive_signal(&lower) { + signals.insert("positive"); } } - covered + + if signals.is_empty() { + signals.insert("positive"); + } + + (signals.len() as u32).min(paths_total) +} + +fn has_positive_signal(value: &str) -> bool { + value.contains("happy") + || value.contains("success") + || value.contains("valid") + || value.contains("authorized") + || value.contains("owner") + || value.contains("admin") + || value.contains("positive") + || value.contains("pass") } -fn count_branches(source: &str) -> u32 { +fn has_negative_signal(kind: BranchKind, value: &str) -> bool { + value.contains("fail") + || value.contains("error") + || value.contains("invalid") + || value.contains("unauthorized") + || value.contains("forbidden") + || value.contains("reject") + || value.contains("revert") + || value.contains("missing") + || value.contains("none") + || value.contains("zero") + || value.contains("overflow") + || matches!(kind, BranchKind::ResultPropagation) && value.contains("not_found") +} + +fn extract_function_name(line: &str) -> Option { + let trimmed = line.trim_start(); + let fn_index = trimmed.find("fn ")?; + let prefix = trimmed[..fn_index].trim(); + + let allowed_prefix = prefix.is_empty() + || prefix == "pub" + || prefix == "async" + || prefix == "pub async" + || prefix.starts_with("pub(") + || prefix.ends_with(" pub") + || prefix.ends_with(" async"); + + if !allowed_prefix { + return None; + } + + let rest = &trimmed[fn_index + 3..]; + let name = rest + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::(); + + (!name.is_empty()).then_some(name) +} + +fn count_function_lines(source: &str, start_line: usize, end_line: usize) -> u32 { source .lines() - .filter(|l| { - let t = l.trim(); - t.starts_with("if ") || t.contains(" match ") || t.starts_with("match ") + .enumerate() + .filter(|(index, line)| { + let line_number = index + 1; + line_number >= start_line && line_number <= end_line && is_code_line(line) }) .count() as u32 } + +fn is_code_line(line: &str) -> bool { + let trimmed = strip_line_comment(line).trim(); + !trimmed.is_empty() + && !trimmed.starts_with("#[") + && trimmed != "{" + && trimmed != "}" + && trimmed != "};" +} + +fn strip_line_comment(line: &str) -> &str { + line.split("//").next().unwrap_or(line) +} + +fn build_visualization( + functions: &[FunctionCoverage], + function_percent: f64, + line_percent: f64, + branch_percent: f64, +) -> CoverageVisualization { + let summary_bars = vec![ + CoverageBar { + label: "Functions".to_string(), + percent: function_percent, + covered: functions.iter().filter(|function| function.covered).count() as u32, + total: functions.len() as u32, + }, + CoverageBar { + label: "Lines".to_string(), + percent: line_percent, + covered: functions + .iter() + .map(|function| function.lines_covered) + .sum::(), + total: functions + .iter() + .map(|function| function.lines_total) + .sum::(), + }, + CoverageBar { + label: "Branches".to_string(), + percent: branch_percent, + covered: functions + .iter() + .map(|function| function.branches_covered) + .sum::(), + total: functions + .iter() + .map(|function| function.branches_total) + .sum::(), + }, + ]; + + let heatmap = functions + .iter() + .map(|function| CoverageHeatmapEntry { + function: function.name.clone(), + line_start: function.start_line, + line_end: function.end_line, + intensity: if function.lines_total == 0 { + 0.0 + } else { + function.lines_covered as f64 / function.lines_total as f64 + }, + covered: function.covered, + }) + .collect(); + + CoverageVisualization { + summary_bars, + heatmap, + } +} + +fn check_goal(label: &str, actual: f64, expected: Option, violations: &mut Vec) { + if let Some(expected) = expected { + if actual + f64::EPSILON < expected { + violations.push(format!( + "{} {:.1}% is below required {:.1}%", + label, actual, expected + )); + } + } +} + +fn percent(covered: u32, total: u32) -> f64 { + if total == 0 { + 100.0 + } else { + round_one((covered as f64 / total as f64) * 100.0) + } +} + +fn weighted_percent(covered: u32, total: u32) -> f64 { + percent(covered, total) +} + +fn round_one(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} + +fn render_html_report(report: &CoverageReport) -> String { + let bars = report + .visualization + .summary_bars + .iter() + .map(|bar| { + format!( + r#"
{}{:.1}%
{}/{}
"#, + html_escape(&bar.label), + bar.percent, + bar.percent.clamp(0.0, 100.0), + bar.covered, + bar.total + ) + }) + .collect::>() + .join("\n"); + + let function_rows = report + .functions + .iter() + .map(|function| { + format!( + "{}{}-{} {}{}/{}{}/{}{}", + html_escape(&function.name), + function.start_line, + function.end_line, + if function.covered { "covered" } else { "missing" }, + function.lines_covered, + function.lines_total, + function.branches_covered, + function.branches_total, + html_escape(&function.test_cases.join(", ")) + ) + }) + .collect::>() + .join("\n"); + + let branch_rows = report + .branches + .iter() + .map(|branch| { + format!( + "{}{}{:?}{}{}/{}{}", + html_escape(&branch.function), + branch.line, + branch.kind, + html_escape(&branch.condition), + branch.paths_covered, + branch.paths_total, + html_escape(&branch.test_cases.join(", ")) + ) + }) + .collect::>() + .join("\n"); + + let goals = report + .goals + .as_ref() + .map(|goals| { + let status = if goals.passed { "passed" } else { "failed" }; + let violations = if goals.violations.is_empty() { + "No goal violations".to_string() + } else { + goals.violations.join("; ") + }; + format!( + "

Coverage goals: {} - {}

", + status, + html_escape(&violations) + ) + }) + .unwrap_or_default(); + + format!( + r#" + + + + StarForge Contract Coverage + + + +

Contract Coverage

+

Overall coverage: {:.1}%

+ {} +
{}
+

Function Coverage

+ + + {} +
FunctionLinesStatusLine CoverageBranch CoverageTests
+

Branch Coverage

+ + + {} +
FunctionLineKindConditionPathsTests
+ +"#, + report.coverage_percent, goals, bars, function_rows, branch_rows + ) +} + +fn render_markdown_report(report: &CoverageReport) -> String { + let mut output = String::new(); + output.push_str("# StarForge Contract Coverage\n\n"); + output.push_str(&format!( + "- Overall: {:.1}%\n- Functions: {:.1}% ({}/{})\n- Lines: {:.1}% ({}/{})\n- Branches: {:.1}% ({}/{})\n\n", + report.coverage_percent, + report.function_coverage_percent, + report.functions_covered, + report.functions_total, + report.line_coverage_percent, + report.lines_covered, + report.lines_total, + report.branch_coverage_percent, + report.branches_covered, + report.branches_total + )); + + if let Some(goals) = &report.goals { + output.push_str(&format!( + "Coverage goals: **{}**\n\n", + if goals.passed { "passed" } else { "failed" } + )); + for violation in &goals.violations { + output.push_str(&format!("- {}\n", violation)); + } + output.push('\n'); + } + + output.push_str("| Function | Lines | Covered | Branch paths | Tests |\n"); + output.push_str("|---|---:|---|---:|---|\n"); + for function in &report.functions { + output.push_str(&format!( + "| {} | {}-{} | {} | {}/{} | {} |\n", + function.name, + function.start_line, + function.end_line, + if function.covered { "yes" } else { "no" }, + function.branches_covered, + function.branches_total, + function.test_cases.join(", ") + )); + } + + output +} + +fn render_text_report(report: &CoverageReport) -> String { + let mut output = String::new(); + output.push_str("StarForge Contract Coverage\n"); + output.push_str(&format!("Overall: {:.1}%\n", report.coverage_percent)); + output.push_str(&format!( + "Functions: {:.1}% ({}/{})\n", + report.function_coverage_percent, report.functions_covered, report.functions_total + )); + output.push_str(&format!( + "Lines: {:.1}% ({}/{})\n", + report.line_coverage_percent, report.lines_covered, report.lines_total + )); + output.push_str(&format!( + "Branches: {:.1}% ({}/{})\n", + report.branch_coverage_percent, report.branches_covered, report.branches_total + )); + + if !report.uncovered_functions.is_empty() { + output.push_str(&format!( + "Uncovered functions: {}\n", + report.uncovered_functions.join(", ") + )); + } + + if let Some(goals) = &report.goals { + output.push_str(&format!( + "Coverage goals: {}\n", + if goals.passed { "passed" } else { "failed" } + )); + for violation in &goals.violations { + output.push_str(&format!(" - {}\n", violation)); + } + } + + output +} + +fn portable_path(path: &Path) -> String { + path.display().to_string().replace('\\', "/") +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn default_passed() -> bool { + true +} diff --git a/src/utils/test_runner.rs b/src/utils/test_runner.rs index b22a0bdd..4674e27d 100644 --- a/src/utils/test_runner.rs +++ b/src/utils/test_runner.rs @@ -1,5 +1,7 @@ use crate::utils::mock_soroban; -use crate::utils::test_coverage::{analyze_source_coverage, CoverageReport}; +use crate::utils::test_coverage::{ + analyze_source_coverage_with_executions, CoverageReport, CoverageTestExecution, +}; use crate::utils::test_generator::{generate_from_source, GeneratedTestCase}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -74,9 +76,11 @@ pub fn run_contract_tests(wasm: &Path, opts: TestOptions) -> Result = - generated_cases.iter().map(|c| c.function.clone()).collect(); - analyze_source_coverage(&content, &executed) + let executions = generated_cases + .iter() + .map(|case| CoverageTestExecution::new(case.name.clone(), case.function.clone())) + .collect::>(); + analyze_source_coverage_with_executions(&content, &executions) }) } else { None @@ -244,7 +248,12 @@ fn write_report(report: &AggregatedReport, format: &str, coverage: bool) -> Resu let cov = report .coverage .as_ref() - .map(|c| format!("

Coverage: {:.1}%

", c.coverage_percent)) + .map(|c| { + format!( + "

Coverage: {:.1}% | Functions: {:.1}% | Branches: {:.1}%

", + c.coverage_percent, c.function_coverage_percent, c.branch_coverage_percent + ) + }) .unwrap_or_default(); let html = format!( "Test Report diff --git a/tests/bridge_integration.rs b/tests/bridge_integration.rs index 8f5d1c28..cba7b62b 100644 --- a/tests/bridge_integration.rs +++ b/tests/bridge_integration.rs @@ -1,8 +1,11 @@ //! Integration tests for cross-chain bridge support. use starforge::utils::bridge::{ - load_config, providers::{BridgeTransferRequest, TransferStatus}, - routes::RouteRegistry, security::SecurityVerifier, state::StateSynchronizer, + load_config, + providers::{BridgeTransferRequest, TransferStatus}, + routes::RouteRegistry, + security::SecurityVerifier, + state::StateSynchronizer, BridgeConfig, }; @@ -59,7 +62,10 @@ fn state_synchronizer_tracks_transfers() { sync.sync("stellar-testnet", "ethereum-sepolia", 100, 200); sync.mark_pending("tx-abc"); sync.mark_completed("tx-abc"); - assert!(sync.state().completed_transfers.contains(&"tx-abc".to_string())); + assert!(sync + .state() + .completed_transfers + .contains(&"tx-abc".to_string())); assert!(sync.state().pending_transfers.is_empty()); } @@ -75,7 +81,8 @@ fn transfer_initiation_produces_result() { sender: "GABC".to_string(), recipient: "0x1234567890123456789012345678901234567890".to_string(), }; - let result = starforge::utils::bridge::providers::initiate_transfer(provider, &request).unwrap(); + let result = + starforge::utils::bridge::providers::initiate_transfer(provider, &request).unwrap(); assert!(!result.transfer_id.is_empty()); assert_eq!(result.status, TransferStatus::SourceConfirmed); } diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 0816e35d..2018f06d 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -531,7 +531,13 @@ fn multisig_create_and_sign_workflow() { let created_path = entries[0].path(); let sign_alice = starforge(home.path()) - .args(["multisig", "sign", created_path.to_str().unwrap(), "--wallet", "alice"]) + .args([ + "multisig", + "sign", + created_path.to_str().unwrap(), + "--wallet", + "alice", + ]) .output() .expect("spawn multisig sign alice"); assert_success(&sign_alice, "starforge multisig sign alice"); @@ -546,7 +552,13 @@ fn multisig_create_and_sign_workflow() { assert!(status_out.contains("50%")); let sign_bob = starforge(home.path()) - .args(["multisig", "sign", created_path.to_str().unwrap(), "--wallet", "bob"]) + .args([ + "multisig", + "sign", + created_path.to_str().unwrap(), + "--wallet", + "bob", + ]) .output() .expect("spawn multisig sign bob"); assert_success(&sign_bob, "starforge multisig sign bob"); @@ -585,13 +597,25 @@ fn multisig_create_and_sign_workflow() { assert_success(&import, "starforge multisig import"); let notify = starforge(home.path()) - .args(["multisig", "notify", import_path.to_str().unwrap(), "--channel", "email"]) + .args([ + "multisig", + "notify", + import_path.to_str().unwrap(), + "--channel", + "email", + ]) .output() .expect("spawn multisig notify"); assert_success(¬ify, "starforge multisig notify"); let submit = starforge(home.path()) - .args(["multisig", "submit", import_path.to_str().unwrap(), "--network", "testnet"]) + .args([ + "multisig", + "submit", + import_path.to_str().unwrap(), + "--network", + "testnet", + ]) .output() .expect("spawn multisig submit"); assert_success(&submit, "starforge multisig submit"); diff --git a/tests/contract_coverage_analysis.rs b/tests/contract_coverage_analysis.rs new file mode 100644 index 00000000..3f50dff5 --- /dev/null +++ b/tests/contract_coverage_analysis.rs @@ -0,0 +1,128 @@ +use starforge::utils::test_coverage::{ + analyze_source_coverage_with_executions, apply_coverage_goals, render_coverage_report, + write_coverage_ci_workflow, write_coverage_report, CoverageGoals, CoverageTestExecution, +}; +use tempfile::TempDir; + +const BRANCHY_SOURCE: &str = r#" +#[contractimpl] +impl Vault { + pub fn deposit(amount: i128) -> i128 { + if amount <= 0 { + panic!("amount must be positive"); + } + amount + } + + pub fn withdraw(amount: i128, balance: i128) -> i128 { + if amount > balance { + panic!("insufficient balance"); + } + balance - amount + } + + pub fn admin_only() { + env.invoker().require_auth(); + } +} +"#; + +#[test] +fn tracks_contract_functions_and_branch_paths() { + let executions = vec![ + CoverageTestExecution::new("deposit happy path", "deposit"), + CoverageTestExecution::new("deposit rejects zero amount", "deposit"), + ]; + + let report = analyze_source_coverage_with_executions(BRANCHY_SOURCE, &executions); + + assert_eq!(report.functions_total, 3); + assert_eq!(report.functions_covered, 1); + assert!(report.uncovered_functions.contains(&"withdraw".to_string())); + assert!(report + .uncovered_functions + .contains(&"admin_only".to_string())); + assert!(report.branches_total >= 6); + assert!(report.branches_covered >= 2); + assert!(report.branch_coverage_percent < 100.0); + assert!( + report + .branches + .iter() + .any(|branch| branch.function == "admin_only" + && branch.condition.contains("require_auth")) + ); +} + +#[test] +fn evaluates_goals_and_renders_reports() { + let executions = vec![CoverageTestExecution::new("deposit happy path", "deposit")]; + let mut report = analyze_source_coverage_with_executions(BRANCHY_SOURCE, &executions); + + let goals = CoverageGoals { + min_overall: Some(90.0), + min_functions: Some(80.0), + min_lines: None, + min_branches: Some(75.0), + }; + let result = apply_coverage_goals(&mut report, goals); + + assert!(!result.passed); + assert!(result + .violations + .iter() + .any(|violation| violation.contains("function coverage"))); + + let html = render_coverage_report(&report, "html").unwrap(); + assert!(html.contains("Contract Coverage")); + assert!(html.contains("Coverage goals")); + assert!(html.contains("deposit")); + + let markdown = render_coverage_report(&report, "markdown").unwrap(); + assert!(markdown.contains("StarForge Contract Coverage")); + assert!(markdown.contains("| Function |")); +} + +#[test] +fn writes_coverage_report_and_ci_workflow() { + let dir = TempDir::new().unwrap(); + let mut report = analyze_source_coverage_with_executions( + BRANCHY_SOURCE, + &[CoverageTestExecution::new("deposit happy path", "deposit")], + ); + apply_coverage_goals( + &mut report, + CoverageGoals { + min_overall: Some(50.0), + min_functions: Some(20.0), + min_lines: None, + min_branches: Some(20.0), + }, + ); + + let report_path = dir.path().join("coverage").join("coverage.json"); + write_coverage_report(&report, "json", &report_path).unwrap(); + let report_json = std::fs::read_to_string(&report_path).unwrap(); + assert!(report_json.contains("\"functions_total\"")); + assert!(report_json.contains("\"goals\"")); + + let workflow_path = dir.path().join(".github/workflows/coverage.yml"); + write_coverage_ci_workflow( + &workflow_path, + std::path::Path::new("target/wasm32-unknown-unknown/release/token.wasm"), + std::path::Path::new("contracts/token/src/lib.rs"), + &CoverageGoals { + min_overall: Some(85.0), + min_functions: None, + min_lines: None, + min_branches: Some(70.0), + }, + ) + .unwrap(); + + let workflow = std::fs::read_to_string(workflow_path).unwrap(); + assert!(workflow.contains("StarForge Contract Coverage")); + assert!(workflow.contains("--coverage-ci")); + assert!(workflow.contains("--coverage-goal 85.0")); + assert!(workflow.contains("--branch-coverage-goal 70.0")); +} diff --git a/tests/contract_framework_integration.rs b/tests/contract_framework_integration.rs index 1de02659..397fde2a 100644 --- a/tests/contract_framework_integration.rs +++ b/tests/contract_framework_integration.rs @@ -3,27 +3,24 @@ use starforge::utils::{ assert_auth_called, assert_balance_eq, assert_balance_gte, assert_err, assert_error_contains, assert_event_count, assert_event_emitted, assert_event_not_emitted, assert_ledger_gte, assert_ok, assert_return_value, assert_storage_absent, - assert_storage_eq, assert_storage_numeric, assert_storage_present, - AssertionStatus, AssertionSuite, ContractAssertions, NumericComparator, + assert_storage_eq, assert_storage_numeric, assert_storage_present, AssertionStatus, + AssertionSuite, ContractAssertions, NumericComparator, }, contract_fixtures::{ - counter_fixture, liquidity_pool_fixture, multisig_fixture, token_fixture, - AccountRole, FixturePhase, FixtureRegistry, StorageDurability, StorageSeed, - save_fixture_snapshot, + counter_fixture, liquidity_pool_fixture, multisig_fixture, save_fixture_snapshot, + token_fixture, AccountRole, FixturePhase, FixtureRegistry, StorageDurability, StorageSeed, }, contract_mocks::{ - counter_env, token_env, MockAddress, MockAuthContext, MockContractClient, - MockEnvironment, MockEvent, MockEventLog, MockLedger, MockStorage, MockTokenBalances, - StorageKey, + counter_env, token_env, MockAddress, MockAuthContext, MockContractClient, MockEnvironment, + MockEvent, MockEventLog, MockLedger, MockStorage, MockTokenBalances, StorageKey, }, contract_test_framework::{ - ContractTestFramework, FrameworkConfig, FrameworkTestSuite, ReportFormat, TestCase, - TestCaseResult, counter_test_suite, token_test_suite, + counter_test_suite, token_test_suite, ContractTestFramework, FrameworkConfig, + FrameworkTestSuite, ReportFormat, TestCase, TestCaseResult, }, contract_test_runner::{ContractTestRunner, TestRunConfig}, testnet_integration::{ - SorobanNetwork, TestnetConfig, TestnetDeployer, TestnetSession, - run_connectivity_smoke_test, + run_connectivity_smoke_test, SorobanNetwork, TestnetConfig, TestnetDeployer, TestnetSession, }, }; use std::io::Write as IoWrite; @@ -68,7 +65,10 @@ fn fixture_counter_full_lifecycle() { assert_eq!(count_seed.value, serde_json::json!(0u64)); assert_eq!(ctx.value("initial_count"), Some(&serde_json::json!(0u64))); - assert_eq!(ctx.metadata.get("contract_type").map(|s| s.as_str()), Some("counter")); + assert_eq!( + ctx.metadata.get("contract_type").map(|s| s.as_str()), + Some("counter") + ); fixture.teardown().unwrap(); } @@ -232,7 +232,9 @@ fn mock_contract_client_full_flow() { let val = client.invoke("get_count", vec![], None, 1).unwrap(); assert_eq!(val, serde_json::json!(0u64)); - let inc = client.invoke("increment", vec![], Some(MockAddress::account(1)), 1).unwrap(); + let inc = client + .invoke("increment", vec![], Some(MockAddress::account(1)), 1) + .unwrap(); assert_eq!(inc, serde_json::json!(1u64)); let err = client.invoke("admin_reset", vec![], None, 1); @@ -250,7 +252,8 @@ fn mock_contract_client_full_flow() { fn mock_environment_full_integration() { let mut env = MockEnvironment::new(); - env.storage.set(StorageKey::instance("admin"), serde_json::json!("GBADMIN")); + env.storage + .set(StorageKey::instance("admin"), serde_json::json!("GBADMIN")); env.balances.mint("TST", "alice", 1_000_000); env.auth.auto_approve(MockAddress::account(1)); @@ -292,10 +295,18 @@ fn mock_ledger_advance_and_timestamp() { fn assertions_storage_full_coverage() { let env = counter_env(); - let eq = assert_storage_eq(&env, &StorageKey::instance("count"), &serde_json::json!(0u64)); + let eq = assert_storage_eq( + &env, + &StorageKey::instance("count"), + &serde_json::json!(0u64), + ); assert_eq!(eq.status, AssertionStatus::Passed); - let neq = assert_storage_eq(&env, &StorageKey::instance("count"), &serde_json::json!(99u64)); + let neq = assert_storage_eq( + &env, + &StorageKey::instance("count"), + &serde_json::json!(99u64), + ); assert_eq!(neq.status, AssertionStatus::Failed); assert!(neq.expected.is_some()); assert!(neq.actual.is_some()); @@ -441,12 +452,8 @@ fn assertion_suite_merge() { let mut a = AssertionSuite::new(); let mut b = AssertionSuite::new(); - a.push(starforge::utils::contract_assertions::AssertionResult::pass( - "test_a", "ok", - )); - b.push(starforge::utils::contract_assertions::AssertionResult::fail( - "test_b", "failed", - )); + a.push(starforge::utils::contract_assertions::AssertionResult::pass("test_a", "ok")); + b.push(starforge::utils::contract_assertions::AssertionResult::fail("test_b", "failed")); a.merge(b); assert_eq!(a.total(), 2); @@ -541,7 +548,11 @@ fn framework_counter_suite_passes() { assert!( result.all_passed(), "counter suite failures: {:?}", - result.results.iter().filter(|r| !r.passed).collect::>() + result + .results + .iter() + .filter(|r| !r.passed) + .collect::>() ); assert_eq!(result.suite_name, "counter"); assert_eq!(result.total, 3); @@ -554,7 +565,11 @@ fn framework_token_suite_passes() { assert!( result.all_passed(), "token suite failures: {:?}", - result.results.iter().filter(|r| !r.passed).collect::>() + result + .results + .iter() + .filter(|r| !r.passed) + .collect::>() ); assert_eq!(result.suite_name, "token"); assert_eq!(result.total, 4); @@ -569,10 +584,8 @@ fn framework_custom_test_case() { "This test always passes", |env| { let start = std::time::Instant::now(); - env.storage.set( - StorageKey::instance("flag"), - serde_json::json!(true), - ); + env.storage + .set(StorageKey::instance("flag"), serde_json::json!(true)); let assertions = ContractAssertions::new(env) .storage_eq(StorageKey::instance("flag"), serde_json::json!(true)) .finish(); diff --git a/tests/deployment_verification.rs b/tests/deployment_verification.rs index eb43746b..db875925 100644 --- a/tests/deployment_verification.rs +++ b/tests/deployment_verification.rs @@ -1,9 +1,7 @@ //! Integration tests for deployment verification system. use starforge::utils::deploy_history::{DeployRecord, DeployStatus}; -use starforge::utils::deployment_verify::{ - generate_ci_snippet, CheckStatus, DeploymentVerifier, -}; +use starforge::utils::deployment_verify::{generate_ci_snippet, CheckStatus, DeploymentVerifier}; fn sample_record() -> DeployRecord { DeployRecord { diff --git a/tests/hardware_wallet_integration.rs b/tests/hardware_wallet_integration.rs index 0cb6bb67..98650174 100644 --- a/tests/hardware_wallet_integration.rs +++ b/tests/hardware_wallet_integration.rs @@ -232,7 +232,10 @@ fn test_hardware_wallet_multisig_sign_flag_documented() { .output() .expect("Failed to get multisig sign help"); - assert!(output.status.success(), "Multisig sign help should be available"); + assert!( + output.status.success(), + "Multisig sign help should be available" + ); let help_text = String::from_utf8_lossy(&output.stdout).to_lowercase(); assert!( help_text.contains("hardware"), @@ -251,7 +254,10 @@ fn test_hardware_wallet_connect_timeout_flag_documented() { .output() .expect("Failed to get wallet connect help"); - assert!(output.status.success(), "Wallet connect help should be available"); + assert!( + output.status.success(), + "Wallet connect help should be available" + ); let help_text = String::from_utf8_lossy(&output.stdout).to_lowercase(); assert!( help_text.contains("timeout"), diff --git a/tests/network_simulation.rs b/tests/network_simulation.rs index 53deda24..cb55d048 100644 --- a/tests/network_simulation.rs +++ b/tests/network_simulation.rs @@ -49,18 +49,30 @@ fn test_simulator_deploy_and_invoke_contract() { let account = sim.create_account(10000.0); // Deploy. - let contract = sim.deploy_contract("test_wasm_v1", &account.public_key).unwrap(); + let contract = sim + .deploy_contract("test_wasm_v1", &account.public_key) + .unwrap(); assert!(contract.contract_id.starts_with('C')); assert_eq!(contract.contract_id.len(), 56); // Simulate invoke. - let result = sim.simulate_invoke(&contract.contract_id, "ping", &[], &account.public_key).unwrap(); + let result = sim + .simulate_invoke(&contract.contract_id, "ping", &[], &account.public_key) + .unwrap(); assert!(result.success); assert!(result.fee_stroops > 0); assert!(result.return_value.starts_with("0x")); // Submit invoke. - let receipt = sim.submit_invoke(&contract.contract_id, "ping", &[], &account.public_key, 100_000).unwrap(); + let receipt = sim + .submit_invoke( + &contract.contract_id, + "ping", + &[], + &account.public_key, + 100_000, + ) + .unwrap(); assert_eq!(receipt.status, "success"); assert_eq!(receipt.function, "ping"); @@ -75,7 +87,9 @@ fn test_simulator_deterministic_reproducibility() { let mut sim = NetworkSimulator::new().with_deterministic_seed(seed); let acct = sim.create_account(500.0); let ctr = sim.deploy_contract("wh", &acct.public_key).unwrap(); - let outcome = sim.simulate_invoke(&ctr.contract_id, "test", &[], &acct.public_key).unwrap(); + let outcome = sim + .simulate_invoke(&ctr.contract_id, "test", &[], &acct.public_key) + .unwrap(); (outcome.return_value, outcome.events[0].clone()) } @@ -92,7 +106,8 @@ fn test_simulator_invoke_reduces_balance() { let ctr = sim.deploy_contract("wh", &acct.public_key).unwrap(); let balance_before = sim.get_account(&acct.public_key).unwrap().balance; - sim.submit_invoke(&ctr.contract_id, "inc", &[], &acct.public_key, 100_000).unwrap(); + sim.submit_invoke(&ctr.contract_id, "inc", &[], &acct.public_key, 100_000) + .unwrap(); let balance_after = sim.get_account(&acct.public_key).unwrap().balance; assert!(balance_after < balance_before); } @@ -103,12 +118,23 @@ fn test_simulator_contract_storage_persistence() { let acct = sim.create_account(1000.0); let ctr = sim.deploy_contract("wh", &acct.public_key).unwrap(); - sim.write_contract_storage(&ctr.contract_id, "key1", "value1").unwrap(); - sim.write_contract_storage(&ctr.contract_id, "key2", "value2").unwrap(); + sim.write_contract_storage(&ctr.contract_id, "key1", "value1") + .unwrap(); + sim.write_contract_storage(&ctr.contract_id, "key2", "value2") + .unwrap(); - assert_eq!(sim.read_contract_storage(&ctr.contract_id, "key1"), Some(&"value1".to_string())); - assert_eq!(sim.read_contract_storage(&ctr.contract_id, "key2"), Some(&"value2".to_string())); - assert_eq!(sim.read_contract_storage(&ctr.contract_id, "nonexistent"), None); + assert_eq!( + sim.read_contract_storage(&ctr.contract_id, "key1"), + Some(&"value1".to_string()) + ); + assert_eq!( + sim.read_contract_storage(&ctr.contract_id, "key2"), + Some(&"value2".to_string()) + ); + assert_eq!( + sim.read_contract_storage(&ctr.contract_id, "nonexistent"), + None + ); } #[test] @@ -117,7 +143,9 @@ fn test_simulator_get_transaction() { let acct = sim.create_account(1000.0); let ctr = sim.deploy_contract("wh", &acct.public_key).unwrap(); - let receipt = sim.submit_invoke(&ctr.contract_id, "fn", &[], &acct.public_key, 100_000).unwrap(); + let receipt = sim + .submit_invoke(&ctr.contract_id, "fn", &[], &acct.public_key, 100_000) + .unwrap(); let found = sim.get_transaction(&receipt.hash); assert!(found.is_some()); assert_eq!(found.unwrap().hash, receipt.hash); @@ -150,19 +178,24 @@ fn test_take_and_restore_snapshot() { let pk = acct.public_key.clone(); let ctr = sim.deploy_contract("wh", &pk).unwrap(); let cid = ctr.contract_id.clone(); - sim.write_contract_storage(&cid, "counter", "10".to_string()).unwrap(); + sim.write_contract_storage(&cid, "counter", "10".to_string()) + .unwrap(); let snap_id = sim.take_snapshot("before-mutation"); // Mutate the state. sim.deduct_balance(&pk, 100.0).unwrap(); - sim.write_contract_storage(&cid, "counter", "20".to_string()).unwrap(); + sim.write_contract_storage(&cid, "counter", "20".to_string()) + .unwrap(); // Restore. sim.restore_snapshot(&snap_id).unwrap(); assert_eq!(sim.get_account(&pk).unwrap().balance, 500.0); - assert_eq!(sim.read_contract_storage(&cid, "counter"), Some(&"10".to_string())); + assert_eq!( + sim.read_contract_storage(&cid, "counter"), + Some(&"10".to_string()) + ); } #[test] @@ -175,9 +208,12 @@ fn test_snapshot_preserves_multiple_contracts() { let c2 = sim.deploy_contract("wasm_b", &pk).unwrap(); let c3 = sim.deploy_contract("wasm_c", &pk).unwrap(); - sim.write_contract_storage(&c1.contract_id, "a", "1").unwrap(); - sim.write_contract_storage(&c2.contract_id, "b", "2").unwrap(); - sim.write_contract_storage(&c3.contract_id, "c", "3").unwrap(); + sim.write_contract_storage(&c1.contract_id, "a", "1") + .unwrap(); + sim.write_contract_storage(&c2.contract_id, "b", "2") + .unwrap(); + sim.write_contract_storage(&c3.contract_id, "c", "3") + .unwrap(); let snap = sim.take_snapshot("multi-contract"); sim.contracts.clear(); @@ -185,7 +221,10 @@ fn test_snapshot_preserves_multiple_contracts() { // Restore should bring back all 3 contracts. sim.restore_snapshot(&snap).unwrap(); assert_eq!(sim.contracts.len(), 3); - assert_eq!(sim.read_contract_storage(&c1.contract_id, "a"), Some(&"1".to_string())); + assert_eq!( + sim.read_contract_storage(&c1.contract_id, "a"), + Some(&"1".to_string()) + ); } #[test] @@ -201,8 +240,24 @@ fn test_snapshot_manager_list_and_remove() { let li = starforge::utils::network_simulator::simulator::LedgerInfo::default(); let lt = LedgerTime::genesis(); - mgr.take_snapshot("s1", &li, &[], &[], <, 0, std::collections::HashMap::new()); - mgr.take_snapshot("s2", &li, &[], &[], <, 0, std::collections::HashMap::new()); + mgr.take_snapshot( + "s1", + &li, + &[], + &[], + <, + 0, + std::collections::HashMap::new(), + ); + mgr.take_snapshot( + "s2", + &li, + &[], + &[], + <, + 0, + std::collections::HashMap::new(), + ); assert_eq!(mgr.list().len(), 2); mgr.remove("snap-0000"); @@ -297,8 +352,7 @@ fn test_failure_injector_rpc_method_filter() { let ctr = sim.deploy_contract("wh", &acct.public_key).unwrap(); sim.failure_injector.add_rule( - FailureRule::new("send-only", FailureMode::BadAuth) - .with_rpc_method("sendTransaction"), + FailureRule::new("send-only", FailureMode::BadAuth).with_rpc_method("sendTransaction"), ); // Simulate should pass (no rule for it). @@ -314,28 +368,31 @@ fn test_failure_injector_rpc_method_filter() { fn test_failure_injector_max_activations() { let mut injector = FailureInjector::new(); injector.enable(); - injector.add_rule( - FailureRule::new("limited", FailureMode::ContractPanic) - .with_max_activations(3), - ); + injector + .add_rule(FailureRule::new("limited", FailureMode::ContractPanic).with_max_activations(3)); for _ in 0..3 { - assert!(injector.check("simulateTransaction", None, None, 1.0).is_some()); + assert!(injector + .check("simulateTransaction", None, None, 1.0) + .is_some()); } // Exhausted. - assert!(injector.check("simulateTransaction", None, None, 1.0).is_none()); + assert!(injector + .check("simulateTransaction", None, None, 1.0) + .is_none()); } #[test] fn test_failure_injector_probability() { let mut injector = FailureInjector::new(); injector.enable(); - injector.add_rule( - FailureRule::new("improbable", FailureMode::ContractPanic).with_probability(0.0), - ); + injector + .add_rule(FailureRule::new("improbable", FailureMode::ContractPanic).with_probability(0.0)); for _ in 0..10 { - assert!(injector.check("simulateTransaction", None, None, 0.99).is_none()); + assert!(injector + .check("simulateTransaction", None, None, 0.99) + .is_none()); } } @@ -363,7 +420,10 @@ fn test_failure_to_rpc_error_all_modes() { FailureMode::InsufficientFee, FailureMode::BadAuth, FailureMode::ContractPanic, - FailureMode::ContractError { code: 1, message: "err".into() }, + FailureMode::ContractError { + code: 1, + message: "err".into(), + }, FailureMode::AccountNotFound, FailureMode::ContractNotFound, FailureMode::InsufficientBalance, @@ -373,9 +433,18 @@ fn test_failure_to_rpc_error_all_modes() { ]; for mode in modes { - let (code, msg) = starforge::utils::network_simulator::failure::failure_to_rpc_error(&mode); - assert!(code < 0, "code should be negative for {:?}, got {}", mode, code); - assert!(!msg.is_empty(), "message should be non-empty for {:?}", mode); + let (code, msg) = starforge::utils::network_simulator::failure::failure_to_rpc_error(&mode); + assert!( + code < 0, + "code should be negative for {:?}, got {}", + mode, + code + ); + assert!( + !msg.is_empty(), + "message should be non-empty for {:?}", + mode + ); } } @@ -429,7 +498,8 @@ fn test_seeded_rng_reproducibility() { #[test] fn test_scenario_simple_counter() { let (sim, result) = ScenarioRunner::run(ScenarioRunner::load_built_in( - BuiltInScenario::SimpleCounter, 42, + BuiltInScenario::SimpleCounter, + 42, )); assert_eq!(sim.accounts.len(), 1); @@ -438,13 +508,17 @@ fn test_scenario_simple_counter() { assert!(result.contracts.contains_key("counter")); let cid = result.contract_id("counter").unwrap(); - assert_eq!(sim.read_contract_storage(cid, "count"), Some(&"0".to_string())); + assert_eq!( + sim.read_contract_storage(cid, "count"), + Some(&"0".to_string()) + ); } #[test] fn test_scenario_token_transfer() { let (sim, result) = ScenarioRunner::run(ScenarioRunner::load_built_in( - BuiltInScenario::TokenTransfer, 42, + BuiltInScenario::TokenTransfer, + 42, )); assert_eq!(sim.accounts.len(), 2); @@ -460,9 +534,8 @@ fn test_scenario_token_transfer() { #[test] fn test_scenario_escrow() { - let (sim, result) = ScenarioRunner::run(ScenarioRunner::load_built_in( - BuiltInScenario::Escrow, 42, - )); + let (sim, result) = + ScenarioRunner::run(ScenarioRunner::load_built_in(BuiltInScenario::Escrow, 42)); assert_eq!(sim.accounts.len(), 3); assert!(result.accounts.contains_key("sender")); @@ -470,13 +543,17 @@ fn test_scenario_escrow() { assert!(result.accounts.contains_key("arbiter")); let cid = result.contract_id("escrow").unwrap(); - assert_eq!(sim.read_contract_storage(cid, "status"), Some(&"\"created\"".to_string())); + assert_eq!( + sim.read_contract_storage(cid, "status"), + Some(&"\"created\"".to_string()) + ); } #[test] fn test_scenario_multisig_vault() { let (sim, result) = ScenarioRunner::run(ScenarioRunner::load_built_in( - BuiltInScenario::MultisigVault, 42, + BuiltInScenario::MultisigVault, + 42, )); assert_eq!(sim.accounts.len(), 3); @@ -486,9 +563,8 @@ fn test_scenario_multisig_vault() { #[test] fn test_scenario_empty() { - let (sim, result) = ScenarioRunner::run(ScenarioRunner::load_built_in( - BuiltInScenario::Empty, 42, - )); + let (sim, result) = + ScenarioRunner::run(ScenarioRunner::load_built_in(BuiltInScenario::Empty, 42)); assert!(sim.accounts.is_empty()); assert!(sim.contracts.is_empty()); @@ -498,9 +574,8 @@ fn test_scenario_empty() { #[test] fn test_scenario_load_test() { - let (sim, result) = ScenarioRunner::run(ScenarioRunner::load_built_in( - BuiltInScenario::LoadTest, 42, - )); + let (sim, result) = + ScenarioRunner::run(ScenarioRunner::load_built_in(BuiltInScenario::LoadTest, 42)); assert_eq!(sim.accounts.len(), 10); assert_eq!(sim.contracts.len(), 3); @@ -511,10 +586,12 @@ fn test_scenario_load_test() { #[test] fn test_scenario_deterministic_across_runs() { let (sim1, _) = ScenarioRunner::run(ScenarioRunner::load_built_in( - BuiltInScenario::TokenTransfer, 42, + BuiltInScenario::TokenTransfer, + 42, )); let (sim2, _) = ScenarioRunner::run(ScenarioRunner::load_built_in( - BuiltInScenario::TokenTransfer, 42, + BuiltInScenario::TokenTransfer, + 42, )); // Same number of accounts and contracts. @@ -568,10 +645,7 @@ fn test_upload_wasm_returns_sha256_hash() { #[test] fn test_simulator_config_with_initial_accounts() { let config = SimulatorConfig { - initial_accounts: vec![ - ("alice".to_string(), 5000.0), - ("bob".to_string(), 3000.0), - ], + initial_accounts: vec![("alice".to_string(), 5000.0), ("bob".to_string(), 3000.0)], ..Default::default() }; let sim = NetworkSimulator::with_config(config); @@ -582,7 +656,8 @@ fn test_simulator_config_with_initial_accounts() { fn test_simulator_get_status() { let mut sim = NetworkSimulator::new(); sim.create_account(100.0); - sim.deploy_contract("wh", &sim.list_accounts()[0].public_key).unwrap(); + sim.deploy_contract("wh", &sim.list_accounts()[0].public_key) + .unwrap(); let status = sim.get_status(); assert_eq!(status["accounts"].as_u64().unwrap(), 1); @@ -595,7 +670,8 @@ fn test_simulator_get_status() { fn test_reset_simulator() { let mut sim = NetworkSimulator::new(); sim.create_account(100.0); - sim.deploy_contract("wh", &sim.list_accounts()[0].public_key).unwrap(); + sim.deploy_contract("wh", &sim.list_accounts()[0].public_key) + .unwrap(); assert_eq!(sim.accounts.len(), 1); assert_eq!(sim.contracts.len(), 1); diff --git a/tests/security_audit_integration.rs b/tests/security_audit_integration.rs index 911393a3..bda906bd 100644 --- a/tests/security_audit_integration.rs +++ b/tests/security_audit_integration.rs @@ -101,7 +101,9 @@ fn score_decreases_with_medium_finding() { #[test] fn score_floored_at_zero_many_criticals() { - let findings: Vec<_> = (0..10).map(|_| make_finding("critical", "builtin")).collect(); + let findings: Vec<_> = (0..10) + .map(|_| make_finding("critical", "builtin")) + .collect(); let result = make_audit_result(findings); assert_eq!(result.score, 0.0); } @@ -250,7 +252,9 @@ fn ci_mode_passes_when_score_meets_threshold() { #[test] fn ci_mode_fails_when_score_below_threshold() { // 4 criticals → score = max(0, 100 - 120) = 0 - let findings: Vec<_> = (0..4).map(|_| make_finding("critical", "builtin")).collect(); + let findings: Vec<_> = (0..4) + .map(|_| make_finding("critical", "builtin")) + .collect(); let result = make_audit_result(findings); assert!( result.score < 60.0,