diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 601924ab..45f32c81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -273,7 +273,7 @@ Tips: ## Claiming and Working on Issues -- Check the issue tracker for open issues and labels like `good first issue` or `help wanted`. +- Check the [issue tracker](https://github.com/Timi16/soroban-debugger/issues) for open issues and labels like [good first issue](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) or [help wanted](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22). - Before starting, comment on the issue to say you want to work on it. - If an issue is already assigned, coordinate in the thread before beginning work. - Keep one issue per PR when possible, and link the PR to the issue. @@ -349,22 +349,22 @@ When suggesting a feature, please include: We welcome contributions in the following areas: **Current Focus:** -- CLI improvements -- Enhanced error messages -- Storage inspection -- Budget tracking +- [CLI improvements](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3ACLI) +- [Enhanced error messages](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3A%22error+messages%22) +- [Storage inspection](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Astorage) +- [Budget tracking](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Abudget) **Upcoming:** -- Breakpoint management -- Terminal UI enhancements -- Call stack visualization -- Execution replay +- [Breakpoint management](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Abreakpoints) +- [Terminal UI enhancements](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3ATUI) +- [Call stack visualization](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3A%22call+stack%22) +- [Execution replay](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Areplay) **Future:** -- WASM instrumentation -- Source map support -- Memory profiling -- Performance analysis +- [WASM instrumentation](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Ainstrumentation) +- [Source map support](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3A%22source+maps%22) +- [Memory profiling](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Aprofiling) +- [Performance analysis](https://github.com/Timi16/soroban-debugger/issues?q=is%3Aopen+is%3Aissue+label%3Aperformance) If you have ideas outside these areas, feel free to discuss them by opening an issue. diff --git a/Cargo.toml b/Cargo.toml index 461f4ea7..bf2c840d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -159,5 +159,7 @@ path = "src/bin/bench-regression.rs" bench = false [lib] +crate-type = ["rlib", "cdylib"] + name = "soroban_debugger" crate-type = ["rlib", "cdylib"] diff --git a/build.rs b/build.rs index 83255ea6..b85448f0 100644 --- a/build.rs +++ b/build.rs @@ -38,6 +38,24 @@ mod debugger { } } +#[allow(dead_code)] +mod analyzer { + pub mod security { + pub enum Severity { + Low, + Medium, + High, + } + } + pub mod symbolic { + pub enum SymbolicProfile { + Fast, + Balanced, + Deep, + } + } +} + #[allow(dead_code)] #[path = "src/cli/args.rs"] mod args; diff --git a/check_output.txt b/check_output.txt new file mode 100644 index 00000000..6b9d0baa --- /dev/null +++ b/check_output.txt @@ -0,0 +1,49 @@ + Checking soroban-debugger v0.1.0 (/Users/backenddevopsdeveloper/Downloads/DRIPS/viv-soroban-debugger) +error[E0425]: cannot find type `DoctorArgs` in this scope + --> src/cli/commands.rs:2944:21 + | +2944 | pub fn doctor(args: DoctorArgs) -> Result<()> { + | ^^^^^^^^^^ not found in this scope + | +help: consider importing this struct + | + 1 + use crate::cli::args::DoctorArgs; + | + +warning: unused imports: `BudgetInspector` and `ResourceCheckpoint` + --> src/output.rs:8:32 + | +8 | use crate::inspector::budget::{BudgetInspector, ResourceCheckpoint}; + | ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +warning: unused import: `std::collections::HashSet` + --> src/server/debug_server.rs:17:5 + | +17 | use std::collections::HashSet; + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused import: `Ordering` + --> src/server/debug_server.rs:22:36 + | +22 | use std::sync::atomic::{AtomicU64, Ordering}; + | ^^^^^^^^ + +warning: unused import: `StorageQuery` + --> src/ui/dashboard.rs:14:51 + | +14 | use crate::inspector::storage::{StorageInspector, StorageQuery}; + | ^^^^^^^^^^^^ + +warning: unused variable: `offsets` + --> src/debugger/source_map.rs:630:21 + | +630 | let offsets = if let Some(offsets) = line_to_offsets.get(requested_line) { + | ^^^^^^^ help: if this is intentional, prefix it with an underscore: `_offsets` + | + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default + +For more information about this error, try `rustc --explain E0425`. +warning: `soroban-debugger` (lib) generated 5 warnings +error: could not compile `soroban-debugger` (lib) due to 1 previous error; 5 warnings emitted diff --git a/docs/debug-cross-contract.md b/docs/debug-cross-contract.md index a3edc931..66e01688 100644 --- a/docs/debug-cross-contract.md +++ b/docs/debug-cross-contract.md @@ -176,7 +176,80 @@ Event: "incremented" = 6 --- -## 8. Git Workflow +## 8. Isolating Cross-Contract Calls with `--mock` + +When debugging the caller contract in isolation, you often do not want the callee contract to execute for real β€” either because it is not deployed locally, its side-effects interfere with the test, or you simply want to focus on the caller's logic. The `--mock` flag lets you intercept any cross-contract call and return a fixed value instead. + +### Syntax + +```bash +soroban-debugger --function \ + --mock ".=" +``` + +The flag is repeatable. Each `--mock` entry specifies: + +| Part | Description | +|---|---| +| `CONTRACT_ID` | The contract address whose calls you want to intercept. | +| `function` | The specific function name on that contract to mock. | +| `return_value` | The value the mock returns to the caller, expressed as a Soroban-compatible literal. | + +### Example: mock the callee during caller debugging + +```bash +soroban-debugger examples/contracts/cross-contract/caller_contract.wasm \ + --function call_increment \ + --mock "CALLEE_CONTRACT_ID.increment=7" +``` + +With this command, any call from `CallerContract` to `CalleeContract::increment` is intercepted and returns `7` immediately β€” the callee WASM never executes. + +### Mocking multiple callees + +```bash +soroban-debugger caller_contract.wasm --function call_increment \ + --mock "CONTRACT_A.increment=7" \ + --mock "CONTRACT_B.get_price=100" +``` + +### Mock call log + +After the session completes, the debugger prints a **Mock Contract Calls** log summarising every cross-contract call observed during execution and whether it was intercepted (`MOCKED`) or passed through to the real contract (`REAL`): + +``` +--- Mock Contract Calls --- +1. MOCKED CALLEE_CONTRACT_ID increment (args: [5]) -> 7 +2. REAL OTHER_CONTRACT_ID other_fn (args: []) -> 42 +``` + +This log helps you verify that mocked call sites were actually reached during the debug session. + +### VS Code launch configuration + +In `.vscode/launch.json`, pass mocks via the `mock` array: + +```json +{ + "type": "soroban-debugger", + "request": "launch", + "mock": [ + "CALLEE_CONTRACT_ID.increment=7" + ] +} +``` + +### When to use `--mock` + +* The callee contract binary is not available locally. +* You want deterministic callee responses to reproduce a specific caller code path. +* You are writing unit-style debugging sessions focused on a single contract boundary. + +For more advanced mock patterns (storage setup, event expectations), see [mock-helpers.md](mock-helpers.md). + +--- + +## 9. Git Workflow ```bash git checkout -b docs/tutorial-cross-contract @@ -190,7 +263,7 @@ git push origin docs/tutorial-cross-contract --- -## 9. Next Steps +## 10. Next Steps * Try nested cross-contract calls and watch the stack grow. * Add more complex callee logic and test how the caller handles it. diff --git a/docs/faq.md b/docs/faq.md index fef65c82..5fec36de 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -69,7 +69,7 @@ soroban-debug inspect --contract my_contract.wasm ### 7. Breakpoints are not triggering **Cause:** You might be setting a breakpoint on a function that is never called, or the function name is slightly different (e.g., due to name mangling). -**Fix:** Verify the function name using `soroban-debug inspect`. In `interactive` mode, use `list-breaks` to ensure your breakpoints are registered. +**Fix:** Verify the function name using `soroban-debug inspect`. In `interactive` mode, use `list-breaks` to ensure your breakpoints are registered and to see their hit counts. ### 8. Can I set a breakpoint on a specific line number? **Answer:** Currently, the debugger supports setting breakpoints only at **function boundaries**. diff --git a/docs/feature-matrix.md b/docs/feature-matrix.md index 272d74b9..8c733b53 100644 --- a/docs/feature-matrix.md +++ b/docs/feature-matrix.md @@ -154,7 +154,7 @@ This matrix is derived from: Related CI contract checks: - Coverage enforcement in `.github/workflows/ci.yml` validates `cargo llvm-cov --json --summary-only` schema and requires `.data[0].totals.lines.percent` to exist as a numeric field. -- Missing-field behavior is regression-tested by `bash scripts/check_benchmark_regressions.sh selftest-coverage-missing-field` to keep schema drift failures actionable. +- Missing-field behavior is regression-tested by `bash scripts/check_benchmark_regressions.sh selftest-coverage-missing-field`; see [Benchmark regression policy](performance-regressions.md#coverage-parser-self-test) for the exact contract that self-test enforces. When adding a new CLI flag or DAP capability, update this file alongside the implementation to keep gaps explicit rather than implicit. diff --git a/docs/index.md b/docs/index.md index fdf83b63..d57b6e9a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,6 +5,7 @@ Welcome to the Soroban Debugger documentation. This index helps you navigate the ## 🏁 Getting Started - [Getting Started Guide](getting-started.md) β€” Your first steps with the debugger. - [First Debug Session](tutorials/first-debug.md) β€” A step-by-step walkthrough. +- [VS Code Extension Setup](tutorials/vscode-extension-setup.md) β€” Install the extension, write `launch.json`, and set your first breakpoints. - [Installation Guide](installation.md) β€” Detailed installation instructions for all platforms. ## πŸ› οΈ Core Features @@ -28,8 +29,10 @@ Welcome to the Soroban Debugger documentation. This index helps you navigate the ## πŸŽ“ Tutorials - [Debugging Auth Errors](tutorials/debug-auth-errors.md) β€” Diagnosing `require_auth()` failures. - [Scenario Runner Cookbook](tutorials/scenario-runner.md) β€” Writing automated integration tests. +- [Plugin Development Tutorial](tutorials/plugin-development.md) β€” Build, install, and iterate on a plugin end-to-end. - [Symbolic Analysis Budgets](tutorials/symbolic-analysis-budgets.md) β€” Configuring symbolic exploration. - [Understanding Budget Trends](tutorials/understanding-budget.md) β€” Visualizing resource usage. +- [Remote Debugging in CI](tutorials/ci-remote-debugging.md) β€” Setting up remote debugging in a CI environment. ## 🀝 Contributing & Community - [Contributing Guide](../CONTRIBUTING.md) β€” How to help improve the debugger. @@ -39,5 +42,7 @@ Welcome to the Soroban Debugger documentation. This index helps you navigate the ## πŸ“„ Reference - [CLI Command Index](cli-command-groups.md) β€” Detailed reference for all CLI subcommands. +- [Benchmark Regression Policy](performance-regressions.md) β€” CI baseline comparison and coverage parser self-test behavior. - [Trace JSON Schema](trace-schema.md) β€” Format of exported execution traces. -- [Plugin API](plugin-api.md) β€” Documentation for the debugger plugin system. +- [Plugin API](plugin-api.md) β€” Reference documentation for the debugger plugin system. +- [Scenario Cookbook](scenario-cookbook.md) β€” Reusable TOML patterns and an end-to-end scenario walkthrough. diff --git a/docs/issues/backlog-100-issues.md b/docs/issues/backlog-100-issues.md index e1524a66..814c1c69 100644 --- a/docs/issues/backlog-100-issues.md +++ b/docs/issues/backlog-100-issues.md @@ -22,8 +22,20 @@ --- +## Planning Workflow + +Use this file as the inventory, then move into the roadmap for execution decisions: + +1. Read the relevant section here to understand the raw issue list and context. +2. Switch to [roadmap-priorities.md](roadmap-priorities.md#section-rollup) for the section rollup, then use the [Priority Table](roadmap-priorities.md#priority-table) and [Wave Plan](roadmap-priorities.md#wave-plan) to decide sequencing. +3. When editing this backlog, update the corresponding roadmap row in the same commit so the two files stay in sync. + +--- + ## Section A β€” README and Landing Docs (Issues 1–12) +Roadmap view: [Section A priorities](roadmap-priorities.md#section-a--readme-and-landing-docs) + - **I-001** `[IA]` README table of contents is missing; readers must scroll through 420 lines to locate sections. - **I-002** `[DOC]` README "Troubleshooting" table has only 3 rows; the full list lives in `docs/remote-troubleshooting.md` without a cross-link from the table. - **I-003** `[DOC]` README "Storage Filtering" section duplicates the same pattern table twice (lines 200–209) β€” consolidate. @@ -41,6 +53,8 @@ ## Section B β€” Architecture and Design Docs (Issues 13–22) +Roadmap view: [Section B priorities](roadmap-priorities.md#section-b--architecture-and-design-docs) + - **I-013** `[DOC]` `ARCHITECTURE.md` describes `Stepper` as "(Planned)" with no follow-up issue or tracking link; its current implementation status is unknown. - **I-014** `[DOC]` `ARCHITECTURE.md` "Extension Points" section lists four items but omits the plugin system, remote server, and batch executor β€” all of which now exist. - **I-015** `[DOC]` No architecture-level doc covers the VS Code extension / DAP adapter; `ARCHITECTURE.md` only covers the Rust CLI. @@ -56,6 +70,8 @@ ## Section C β€” Feature Reference Docs (Issues 23–40) +Roadmap view: [Section C priorities](roadmap-priorities.md#section-c--feature-reference-docs) + - **I-023** `[DOC]` `docs/instruction-stepping.md` (11 KB) covers the feature thoroughly but has no link back to the feature matrix β€” readers don't know which surfaces support it. - **I-024** `[DOC]` `docs/remote-debugging.md` covers TLS setup but doesn't show a complete `launch.json` snippet for the VS Code attach flow. - **I-025** `[DOC]` `docs/remote-troubleshooting.md` references a "Local and CI Sandbox Failures" section that exists, but the FAQ (question 27) links to it using anchor syntax that doesn't match the actual heading casing. @@ -79,23 +95,27 @@ ## Section D β€” Tutorials (Issues 41–52) +Roadmap view: [Section D priorities](roadmap-priorities.md#section-d--tutorials) + - **I-041** `[DOC]` `docs/tutorials/first-debug.md` doesn't reference the `.soroban-debug.toml` config file, which new users would benefit from knowing about early. - **I-042** `[DOC]` `docs/tutorials/scenario-runner.md` shows TOML structure but doesn't document all TOML keys (e.g., `timeout`, `expected_events`, `skip`). - **I-043** `[DOC]` `docs/tutorials/debug-auth-errors.md` has empty checkbox items that suggest the tutorial is incomplete. -- **I-044** `[DOC]` `docs/tutorials/symbolic-analysis-budgets.md` doesn't explain how to interpret the exploration report or act on findings. -- **I-045** `[DOC]` `docs/tutorials/understanding-budget.md` covers CPU/memory budget but doesn't mention the `--budget-trend` flag or history-based regression detection. +- **I-044** ~`[DOC]` `docs/tutorials/symbolic-analysis-budgets.md` doesn't explain how to interpret the exploration report or act on findings.~ +- **I-045** ~`[DOC]` `docs/tutorials/understanding-budget.md` covers CPU/memory budget but doesn't mention the `--budget-trend` flag or history-based regression detection.~ - **I-046** `[DOC]` `docs/doc/tutorials/video-token-transfer.md` lives under `docs/doc/tutorials/` rather than `docs/tutorials/` β€” inconsistent nesting that breaks the docs IA. - **I-047** `[IA]` No tutorial covers plugin development end-to-end; `docs/plugin-api.md` is a reference, not a tutorial. - **I-048** `[DOC]` No tutorial covers the VS Code extension setup (installing the extension, writing a `launch.json`, setting breakpoints). - **I-049** `[DOC]` No tutorial covers using the TUI (`soroban-debug tui`) β€” the feature is mentioned in the command index but has no guide. - **I-050** `[DOC]` No tutorial covers the upgrade-check workflow (building two WASM versions, running the check, interpreting Safe/Caution/Breaking output). - **I-051** `[DOC]` No tutorial covers the REPL (`soroban-debug repl`) β€” how to enter it, issue commands, and exit. -- **I-052** `[DOC]` No tutorial covers remote debugging in a CI environment (the typical DevOps use case beyond the local SSH-tunnel workaround). +- **I-052** ~`[DOC]` No tutorial covers remote debugging in a CI environment (the typical DevOps use case beyond the local SSH-tunnel workaround).~ --- ## Section E β€” Contributor Workflow (Issues 53–70) +Roadmap view: [Section E priorities](roadmap-priorities.md#section-e--contributor-workflow) + - **I-053** `[CONTRIB]` `CONTRIBUTING.md` says "Check the issue tracker for open issues and labels like `good first issue`" but doesn't link to the actual filtered GitHub URL. - **I-054** `[CONTRIB]` `CONTRIBUTING.md` "Areas for Contribution" lists items in free text; it should link to concrete open GitHub issues or project board columns. - **I-055** `[CONTRIB]` `CONTRIBUTING.md` describes the PR checklist but does not explain what "CI/test behavior changes" means or give examples of what N/A covers. @@ -119,6 +139,8 @@ ## Section F β€” Release Operations (Issues 71–82) +Roadmap view: [Section F priorities](roadmap-priorities.md#section-f--release-operations) + - **I-071** `[RELEASE]` `docs/release-checklist.md` requires `CHANGELOG.md` to be updated but provides no example entry format, no link to `cliff.toml`, and no `git-cliff` invocation command. - **I-072** `[RELEASE]` The release checklist sign-off section uses `@____` placeholder syntax; there is no documented process for assigning owners before a release cycle begins. - **I-073** `[RELEASE]` The benchmark threshold (10%/20%) is hardcoded in the release checklist but the actual values are also set in CI scripts β€” the two can drift without detection. @@ -136,6 +158,8 @@ ## Section G β€” Repo Health and Meta (Issues 83–93) +Roadmap view: [Section G priorities](roadmap-priorities.md#section-g--repo-health-and-meta) + - **I-083** `[META]` Several implementation-summary files (`BATCH_EXECUTION_SUMMARY.md`, `IMPLEMENTATION_SUMMARY.md`, `PLUGIN_RELOAD_DIFF_IMPLEMENTATION.md`, `FLAMEGRAPH_IMPLEMENTATION.md`) live in the root and appear to be one-off delivery notes rather than living documentation; a policy for these files is needed. - **I-084** `[META]` `PR_DESCRIPTION.md` lives in the repo root β€” it appears to be a leftover from a PR and should be removed or archived. - **I-085** `[META]` No `SECURITY.md` file exists; GitHub displays a warning and security researchers have no disclosed contact point. @@ -152,6 +176,8 @@ ## Section H β€” DX and Tooling Quality (Issues 94–100) +Roadmap view: [Section H priorities](roadmap-priorities.md#section-h--dx-and-tooling-quality) + - **I-094** `[DX]` `docs/getting-started.md` (3001 bytes) is the natural entry point for new users but is not linked from the README "Quick Start" section. - **I-095** `[DX]` The `feature-matrix.md` "Maintaining This Document" section tells editors which source files to check but doesn't describe a process to detect drift (e.g., a CI check that flags undocumented flags). - **I-096** `[DX]` Man pages in `man/man1/` are generated but there's no published HTML equivalent β€” the `cargo doc` output and man pages are the only machine-generated references. diff --git a/docs/issues/roadmap-priorities.md b/docs/issues/roadmap-priorities.md index a7c99479..6d5ed5ae 100644 --- a/docs/issues/roadmap-priorities.md +++ b/docs/issues/roadmap-priorities.md @@ -23,6 +23,25 @@ --- +## Section Rollup + +Use this table before you dive into the 100-row priority matrix. It gives each +backlog section a recommended entry point so maintainers can open issues in a +useful order instead of re-triaging the whole epic every time. + +| Section | Backlog slice | Planning focus | Start with | Why this is the entry point | +|---------|---------------|----------------|------------|-----------------------------| +| **A** | [README and Landing Docs](backlog-100-issues.md#section-a--readme-and-landing-docs) | New-user discoverability and release-facing repo hygiene | `I-009`, then `I-007` | `I-009` is a P0 FAQ gap; `I-007` unlocks several later navigation fixes. | +| **B** | [Architecture and Design Docs](backlog-100-issues.md#section-b--architecture-and-design-docs) | Fill missing system-shape docs before dependent tutorials land | `I-015` | The VS Code / DAP architecture doc unblocks `I-024` and `I-048`. | +| **C** | [Feature Reference Docs](backlog-100-issues.md#section-c--feature-reference-docs) | Close reference gaps that affect discoverability and advanced workflows | `I-023`, then `I-030` | `I-023` is a fast cross-link win; `I-030` unlocks plugin tutorial work. | +| **D** | [Tutorials](backlog-100-issues.md#section-d--tutorials) | Repair broken learning paths, then add missing workflows | `I-043`, then `I-046` | `I-043` is the only P0 in this section; `I-046` improves tutorial discoverability immediately. | +| **E** | [Contributor Workflow](backlog-100-issues.md#section-e--contributor-workflow) | Remove contributor blockers, then formalize process | `I-058`, then `I-053` | `I-058` fixes a missing referenced file; `I-053` is an easy on-ramp for contribution flow. | +| **F** | [Release Operations](backlog-100-issues.md#section-f--release-operations) | Establish release-critical documentation before automation depth | `I-071` | It is P0 and also unblocks follow-on `git-cliff` docs in contributor guidance. | +| **G** | [Repo Health and Meta](backlog-100-issues.md#section-g--repo-health-and-meta) | Clean repo-level trust and navigation issues | `I-085`, then `I-083` | `I-085` is security-critical; `I-083` prevents future root-level doc sprawl. | +| **H** | [DX and Tooling Quality](backlog-100-issues.md#section-h--dx-and-tooling-quality) | Improve entry points first, then automate drift detection | `I-094` | It is a P0 fix on the primary onboarding path and is independent of later tooling work. | + +--- + ## Priority Table ### Section A β€” README and Landing Docs @@ -87,15 +106,15 @@ | I-041 | Reference `.soroban-debug.toml` in `first-debug.md` | P1 | XS | Docs | I-004 | 2 | | I-042 | Document all TOML keys in `scenario-runner.md` | P1 | M | Docs | β€” | 2 | | I-043 | Complete empty checklist items in `debug-auth-errors.md` | P0 | S | Docs | β€” | 1 | -| I-044 | Add report interpretation to `symbolic-analysis-budgets.md` | P2 | M | Docs | β€” | 2 | -| I-045 | Add `--budget-trend` flag coverage to `understanding-budget.md` | P1 | S | Docs | β€” | 2 | +| I-044 | ~Add report interpretation to `symbolic-analysis-budgets.md`~ | P2 | M | Docs | β€” | 2 | +| I-045 | ~Add `--budget-trend` flag coverage to `understanding-budget.md`~ | P1 | S | Docs | β€” | 2 | | I-046 | Relocate `docs/doc/tutorials/video-token-transfer.md` to `docs/tutorials/` | P1 | XS | Docs | I-007 | 1 | | I-047 | Write end-to-end plugin development tutorial | P2 | L | Docs | I-030, I-026 | 3 | | I-048 | Write VS Code extension setup tutorial | P1 | M | Docs | I-015, I-024 | 2 | | I-049 | Write TUI (`soroban-debug tui`) tutorial | P2 | M | Docs | β€” | 3 | | I-050 | Write upgrade-check workflow tutorial | P2 | M | Docs | I-034 | 3 | | I-051 | Write REPL tutorial | P2 | M | Docs | β€” | 3 | -| I-052 | Write remote debugging in CI tutorial | P2 | L | Docs | I-024, I-048 | 3 | +| I-052 | ~Write remote debugging in CI tutorial~ | P2 | L | Docs | I-024, I-048 | 3 | ### Section E β€” Contributor Workflow @@ -314,3 +333,11 @@ acceptance criteria. 5. **Review this file** at the end of each wave β€” demote any issues that turn out to be lower value than expected, and promote anything the wave work revealed as higher priority. + +--- + +## Maintenance Rules + +1. When adding or removing an issue in [backlog-100-issues.md](backlog-100-issues.md), update the matching roadmap row in the same commit. +2. Keep the short title in this file close to the backlog wording so maintainers can grep for an ID and compare the two documents quickly. +3. When a dependency changes, update both the `Depends on` column and the relevant wave summary so the wave plan stays executable rather than historical. diff --git a/docs/performance-regressions.md b/docs/performance-regressions.md index c9a41b95..4a4aec68 100644 --- a/docs/performance-regressions.md +++ b/docs/performance-regressions.md @@ -46,3 +46,19 @@ cargo bench --benches -- --noplot cargo run --bin bench-regression -- compare --baseline .bench/baseline.json --criterion target/criterion ``` +## Coverage parser self-test + +The benchmark helper script also contains a schema-regression self-test for coverage parsing: + +```bash +bash scripts/check_benchmark_regressions.sh selftest-coverage-missing-field +``` + +This mode does not run Criterion benchmarks. Instead, it feeds intentionally incomplete coverage JSON into `coverage-percent-from-json` and verifies two behaviors: + +- The parser exits with a failure when `.data[0].totals.lines.percent` is missing. +- The error message explicitly names the missing numeric field so CI failures remain actionable when the `cargo llvm-cov --json --summary-only` schema drifts. + +Run this self-test when you change the coverage parsing logic, the CI workflow around coverage extraction, or the docs that describe the coverage contract. This is the self-test referenced in [Feature Matrix](feature-matrix.md#maintaining-this-document). + +`jq` must be available on `PATH`; the script will fail early if it is missing. diff --git a/docs/plugin-api.md b/docs/plugin-api.md index 9a615b6d..37c1ea63 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -366,6 +366,7 @@ Fired when a breakpoint is hit. ExecutionEvent::BreakpointHit { function: String, condition: Option, + hit_count: u64, } ``` diff --git a/docs/remote-capabilities.md b/docs/remote-capabilities.md new file mode 100644 index 00000000..5d96740c --- /dev/null +++ b/docs/remote-capabilities.md @@ -0,0 +1,116 @@ +# Remote Debugging Capability Negotiation + +## Overview + +When a client connects to a remote Soroban debugger server, both sides now exchange capability metadata during the handshake. This allows incompatibilities to be detected **at connection time** rather than later when operations are attempted. + +## How It Works + +### Connection Handshake Sequence + +``` +Client Server + | | + |--- Connect (TCP) ------------------> | + | | + |--- Handshake Request | + | (client_name, client_version, | + | protocol_version, | + | required_capabilities) --------> | + | | + | [Validate protocol version] + | [Build server capabilities] + | [Check compatibility] + | | + |<--- Handshake Response | + | (server_version, | + | server_capabilities, | + | negotiated_features) ---------- | + | | + |--- Authenticate (if token) --------> | + | | + |<--- Auth Response -------------------- | + | | + | [Ready for operations] | + | | +``` + +## Supported Capabilities + +The following capabilities can be negotiated: + +| Capability | Description | +|---|---| +| `conditional_breakpoints` | Supports conditional and hit-count breakpoints | +| `source_breakpoints` | Supports source-level (DWARF) breakpoints via `ResolveSourceBreakpoints` | +| `evaluate` | Supports the `Evaluate` request for expression inspection | +| `tls` | Supports TLS-encrypted connections | +| `token_auth` | Supports token-based authentication | +| `session_lifecycle` | Supports heartbeat/idle-timeout negotiation | +| `repeat_execution` | Supports repeat execution via `repeat_count` | +| `symbolic_analysis` | Supports the symbolic analysis command | +| `snapshot_loading` | Supports loading network snapshots via `LoadSnapshot` | +| `dynamic_trace_events` | Supports the `GetEvents` / DynamicTrace command | + +## Error Scenarios + +### Scenario 1: Client Requires Feature Server Doesn't Support + +**Client declares:** `required_capabilities: { evaluate: true, snapshot_loading: true }` + +**Server supports:** `{ evaluate: true, snapshot_loading: false, ... }` + +**Result:** Connection rejected at handshake with error: +``` +Server is missing required capabilities [snapshot_loading]. +Upgrade the server or disable these features on the client. +``` + +### Scenario 2: Both Support All Required Features + +**Client declares:** `required_capabilities: { evaluate: true }` + +**Server supports:** `{ evaluate: true, ... }` + +**Result:** Connection succeeds; operations proceed normally + +## Backward Compatibility + +- **Old clients connecting to new servers:** If the client doesn't send `required_capabilities`, the server treats it as having no requirements and accepts the connection. +- **New clients connecting to old servers:** If the server doesn't advertise capabilities, the client treats it as supporting nothing optional. + +## Usage Examples + +### Rust Client + +```rust +use soroban_debugger::client::RemoteClient; +use soroban_debugger::server::protocol::ServerCapabilities; + +// Create a client that requires specific capabilities +let mut config = RemoteClientConfig::default(); +config.required_capabilities = Some(ServerCapabilities { + evaluate: true, + snapshot_loading: true, + ..Default::default() +}); + +let mut client = RemoteClient::connect_with_config( + "127.0.0.1:8000", + None, + config, +)?; + +// If server doesn't support evaluate, this fails at handshake +``` + +## Troubleshooting + +### "Server is missing required capabilities" + +**Cause:** The server build doesn't support a feature the client needs. + +**Solutions:** +1. Upgrade the server to a newer version that supports the feature +2. Disable the feature requirement on the client side +3. Check the server's capability list to see what it does support diff --git a/docs/scenario-cookbook.md b/docs/scenario-cookbook.md index bcbf6ea7..f791b516 100644 --- a/docs/scenario-cookbook.md +++ b/docs/scenario-cookbook.md @@ -137,3 +137,218 @@ args = "[1, 2]" max_cpu_instructions = 10000 max_memory_bytes = 1024 ``` + +--- + +## πŸš€ End-to-End Walkthrough + +This section goes beyond isolated snippets to show the full workflow: authoring a scenario file, running it, and reviewing the execution trace to understand what happened. + +We'll use the `simple-token` example contract from `examples/contracts/simple-token/` β€” a straightforward fungible token with `initialize`, `mint`, `transfer`, and `balance` functions. + +### Step 1: Author the Scenario TOML + +Create a file named `token_lifecycle.toml` in your working directory: + +```toml +# token_lifecycle.toml +# End-to-end test for the simple-token contract. +# Covers: initialization, minting, transfer, balance verification, and an +# expected-failure case for overdrawing. + +[defaults] +timeout_secs = 30 + +# ── Setup ──────────────────────────────────────────────────────────────────── + +[[steps]] +name = "Initialize Token" +function = "initialize" +args = '["GD5DJ3B6A2KHSXLYJZ3IGR7Q5UMVJ5J4GQTKTQYQDQXJQJ5YQZQKQZQ", "My Token", "MTK"]' +expected_return = "()" + +# ── Funding ────────────────────────────────────────────────────────────────── + +[[steps]] +name = "Mint 1 000 tokens to Alice" +function = "mint" +args = '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", 1000]' +expected_return = "()" + +[[steps]] +name = "Confirm Alice balance after mint" +function = "balance" +args = '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ"]' +expected_return = "1000" + +# ── Transfer ───────────────────────────────────────────────────────────────── + +[[steps]] +name = "Alice transfers 300 tokens to Bob" +function = "transfer" +args = '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", "GD826E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", 300]' +expected_return = "()" + +# ── State verification ─────────────────────────────────────────────────────── + +[[steps]] +name = "Verify Alice's remaining balance" +function = "balance" +args = '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ"]' +expected_return = "700" + +[steps.expected_storage] +"TotalSupply" = "1000" + +[[steps]] +name = "Verify Bob's balance" +function = "balance" +args = '["GD826E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ"]' +expected_return = "300" + +# ── Failure guard ──────────────────────────────────────────────────────────── + +[[steps]] +name = "Overdraw attempt must fail" +function = "transfer" +args = '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", "GD826E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", 9999]' +expected_error = "insufficient" +``` + +Key authoring decisions made here: + +- **`[defaults]`** sets a 30-second timeout for all steps; individual steps can override it. +- Steps are grouped with comments into setup, funding, transfer, verification, and failure phases β€” this makes failures easier to locate. +- `expected_storage` on the verification step pins contract-level state, not just the return value. +- The final step uses `expected_error` to assert that the contract correctly rejects an overdraw; the runner treats a matching error as a pass. + +### Step 2: Build the Contract + +```bash +cd examples/contracts/simple-token +cargo build --target wasm32-unknown-unknown --release +``` + +The compiled WASM lands at: + +``` +target/wasm32-unknown-unknown/release/simple_token.wasm +``` + +### Step 3: Run the Scenario + +From the repository root: + +```bash +soroban-debugger scenario \ + --contract examples/contracts/simple-token/target/wasm32-unknown-unknown/release/simple_token.wasm \ + --scenario token_lifecycle.toml +``` + +Add `--verbose` to see per-instruction budget details on each step. + +### Step 4: Read the Execution Trace + +A successful run prints a step-by-step trace to stdout: + +``` +ℹ️ Loading scenario file: "token_lifecycle.toml" +ℹ️ Loading contract: "simple_token.wasm" +βœ… Running 7 scenario steps... + +ℹ️ Step 1: Initialize Token + Result: () + βœ… Return value assertion passed +βœ… Step 1 passed. + +ℹ️ Step 2: Mint 1 000 tokens to Alice + Result: () + βœ… Return value assertion passed +βœ… Step 2 passed. + +ℹ️ Step 3: Confirm Alice balance after mint + Result: 1000 + βœ… Return value assertion passed +βœ… Step 3 passed. + +ℹ️ Step 4: Alice transfers 300 tokens to Bob + Result: () + βœ… Return value assertion passed +βœ… Step 4 passed. + +ℹ️ Step 5: Verify Alice's remaining balance + Result: 700 + βœ… Return value assertion passed + βœ… Storage assertion passed for key 'TotalSupply' +βœ… Step 5 passed. + +ℹ️ Step 6: Verify Bob's balance + Result: 300 + βœ… Return value assertion passed +βœ… Step 6 passed. + +ℹ️ Step 7: Overdraw attempt must fail + Error: "insufficient balance" + βœ… Error assertion matched 'insufficient' +βœ… Step 7 passed. + +βœ… All 7 scenario steps passed successfully! +``` + +**How to read the trace:** + +| Line pattern | Meaning | +|---|---| +| `ℹ️ Step N: ` | A new step is starting | +| ` Result: ` | The raw return value from the contract | +| `βœ… Return value assertion passed` | `expected_return` matched | +| `βœ… Storage assertion passed for key ''` | That `expected_storage` key matched | +| `βœ… Error assertion matched ''` | The actual error contains the expected substring | +| `βœ… Step N passed.` | All assertions on this step passed | +| `βœ… All N scenario steps passed successfully!` | The whole scenario is green | + +### Step 5: Diagnose a Failure + +Suppose step 5 fails because a bug causes the transfer to deduct tokens twice: + +``` +ℹ️ Step 5: Verify Alice's remaining balance + Result: 400 + ❌ Return value assertion failed! Expected '700', got '400' +⚠️ Step 5 failed. +``` + +**Triage workflow:** + +1. **Isolate the step** β€” comment out steps 6 and 7 in the TOML, re-run to confirm the failure is reproducible in isolation. +2. **Add a storage assertion** β€” add `expected_storage` to step 4 to verify what the contract wrote after the transfer: + + ```toml + [[steps]] + name = "Alice transfers 300 tokens to Bob" + function = "transfer" + args = '["GD726...", "GD826...", 300]' + expected_return = "()" + + [steps.expected_storage] + "Balance:GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ" = "700" + ``` + +3. **Inspect with the debugger** β€” switch from `scenario` to `interactive` to step through the transfer call: + + ```bash + soroban-debugger interactive \ + --contract simple_token.wasm \ + --function transfer \ + --args '["GD726...", "GD826...", 300]' + ``` + +4. **Compare traces** β€” use `soroban-debugger compare` if you have a known-good trace to diff against the failing one. + +Once the bug is fixed, re-run the full scenario to confirm all 7 steps pass again. + +### Next Steps + +- Add more failure guards using `expected_panic` for contracts that use `panic!` instead of returning errors. +- Extract shared setup steps into an `include`d file (see [Scenario Runner Tutorial](tutorials/scenario-runner.md)). +- Combine this scenario with the symbolic analyzer to auto-generate edge-case steps (see the [Scenario Runner Tutorial Β§ Symbolic Analysis](tutorials/scenario-runner.md#symbolic-analysis)). diff --git a/docs/source-level-debugging.md b/docs/source-level-debugging.md index 14341187..d85b60a8 100644 --- a/docs/source-level-debugging.md +++ b/docs/source-level-debugging.md @@ -21,6 +21,48 @@ Soroban contracts are compiled from Rust to WebAssembly (WASM). While debugging - **Caching**: Performance optimized with file and mapping caches. - **Fallback & Diagnostics**: Graceful fallback to WASM-only view if debug info is missing or stripped. When DWARF metadata is partially malformed, `SourceMap::load` continues to extract valid data and surfaces parsing errors as warnings (`SourceMapDiagnostic`) rather than completely aborting. These diagnostics can be reviewed using `inspect`. +## When DWARF Is Absent: Heuristic Fallback + +Production Soroban WASM binaries are commonly stripped of debug symbols to reduce size. When the debugger cannot find valid DWARF sections in the binary, it does not fail outright β€” it switches to a heuristic fallback mode. + +### What the heuristic fallback does + +Instead of using DWARF to map instruction offsets to source lines, the debugger falls back to **function-level mapping**: it identifies exported contract entrypoint functions from the WASM export table and maps breakpoints to those function boundaries. + +This means: + +- **Source breakpoints become function breakpoints.** A breakpoint set on `src/lib.rs:10` will be matched heuristically to the nearest exported function that contains that line (if one can be inferred). Execution still pauses, but at the function entry rather than at the exact line. +- **Step-by-step source navigation is unavailable.** The Source pane falls back to a WASM instruction view because there are no line mappings to follow. +- **The `HEURISTIC_NO_DWARF` reason code is set.** When the VS Code adapter reports breakpoint status, it uses `verified=false` and `reasonCode=HEURISTIC_NO_DWARF` to signal that the mapping is approximate. + +### Breakpoint response fields under heuristic fallback + +| Field | Value | Meaning | +|---|---|---| +| `verified` | `false` | No exact source-to-runtime proof was available. | +| `reasonCode` | `HEURISTIC_NO_DWARF` | DWARF was absent; heuristic function mapping was used instead. | +| `setBreakpoint` | `true` (if matched) | A runtime function breakpoint was still installed. | + +### How to get full source-level debugging + +Compile your contract with debug symbols: + +```bash +cargo build # debug build retains DWARF by default +``` + +Avoid passing `--release` or running `wasm-opt` on the binary you intend to debug, as both strip or alter debug sections. + +### Diagnosing fallback mode + +Run `inspect` on the binary to see which fallback mode the debugger will use and whether any partial DWARF data was recoverable: + +```text +inspect +``` + +The output includes a source-map health summary that reports mapping coverage and the active fallback mode. See also [source-map-health.md](source-map-health.md) and the [FAQ entry on `verified=false` breakpoints](faq.md). + ## Limitations - **Stripped Binaries**: Production Soroban WASM files are often stripped to save space. Debug info is only available in binaries compiled with debug symbols (e.g., `cargo build`). diff --git a/docs/symbolic-coverage.md b/docs/symbolic-coverage.md new file mode 100644 index 00000000..34bb6d85 --- /dev/null +++ b/docs/symbolic-coverage.md @@ -0,0 +1,255 @@ +# Symbolic Coverage Reporting + +## Overview + +The Soroban debugger now provides **coverage metrics** for symbolic execution runs, helping you understand how thoroughly your contract was explored. These metrics tell you not just *what* happened during symbolic analysis, but *how complete* the exploration was. + +## Coverage Metrics + +When you run `soroban-debug symbolic`, the report now includes a **Coverage Summary** section with the following metrics: + +### Functions Reached + +``` +Functions reached: 3/12 (25.0%) +``` + +This shows: +- **Unique functions reached**: How many contract functions were actually executed during symbolic exploration +- **Total functions available**: The total number of exported functions in the WASM module +- **Percentage**: The ratio of reached to available functions + +**Why this matters**: If symbolic execution only reached 25% of your contract's functions, you may need to: +- Increase `--path-cap` to explore more execution paths +- Provide different input combinations that trigger other functions +- Use `--profile deep` for more thorough exploration + +### Branches Touched + +``` +Branches touched: 8 (estimated from distinct paths) +``` + +This metric estimates branch coverage by counting distinct execution paths discovered. Each unique path through the code represents at least one branch decision that was explored. + +**Interpretation**: +- Higher numbers indicate more thorough branch exploration +- This is a *conservative estimate* - each path may actually touch multiple branches +- Use this to gauge whether you're seeing diverse execution flows + +### Duplicates Suppressed + +``` +Duplicates suppressed: 15 +``` + +Shows how many input combinations were skipped because they were identical to previously tested inputs. This happens when: +- The input generation produces redundant combinations +- Multiple input sets lead to the same execution path + +**Why this matters**: High duplicate counts suggest: +- Your input generation strategy could be optimized +- The contract may have many equivalent input paths +- Consider using `--seed` to shuffle exploration order + +### Exploration Completeness + +The report includes an indicator showing whether exploration completed fully: + +``` +βœ“ Exploration completed without hitting caps +``` + +Or warnings if exploration was limited: + +``` +⚠ Exploration hit path cap - may not be complete +⚠ Exploration timed out - may not be complete +``` + +**What to do if you see warnings**: +- **Path cap reached**: Increase `--path-cap` (default: 100) +- **Timeout reached**: Increase `--timeout` (default: 30s) +- Consider using `--profile deep` for maximum exploration + +## Example Output + +``` +Function: transfer +Paths explored: 47 +Panics found: 2 +Replay token: 42 +Budget: path_cap=100, input_combination_cap=256, timeout=30s +Input combinations: generated=256, attempted=47, distinct_paths=12 + +Coverage Summary: + Functions reached: 3/12 (25.0%) + Branches touched: 12 (estimated from distinct paths) + Duplicates suppressed: 35 + βœ“ Exploration completed without hitting caps + +Truncation: none + +Distinct paths: + 1. inputs=["GAAA...", "GBBB...", 100] -> return Ok(Void) + 2. inputs=["GAAA...", "GBBB...", 0] -> panic Error(Contract, #1) + ... +``` + +## Interpreting Coverage Results + +### Good Coverage Indicators + +- **Functions reached > 50%**: Symbolic execution is exploring a significant portion of your contract +- **Low duplicate ratio**: Input generation is efficient and diverse +- **No truncation warnings**: Exploration completed without hitting limits +- **Multiple distinct paths**: Contract logic has been tested under various conditions + +### Poor Coverage Indicators + +- **Functions reached < 20%**: Many contract functions were never executed +- **High duplicate ratio (>50%)**: Input generation is producing redundant combinations +- **Path cap reached**: You need to increase exploration limits +- **Only 1-2 distinct paths**: Contract may have limited branching or inputs aren't diverse enough + +## Improving Coverage + +### 1. Increase Exploration Limits + +```bash +# Increase path cap to explore more execution paths +soroban-debug symbolic contract.wasm --function transfer --path-cap 500 + +# Increase timeout for complex contracts +soroban-debug symbolic contract.wasm --function transfer --timeout 120 +``` + +### 2. Use Deep Profile + +```bash +# Maximum exploration breadth and depth +soroban-debug symbolic contract.wasm --function transfer --profile deep +``` + +### 3. Shuffle Exploration Order + +```bash +# Use a seed to shuffle input exploration order +soroban-debug symbolic contract.wasm --function transfer --seed 42 + +# Reproduce the same exploration later +soroban-debug symbolic contract.wasm --function transfer --replay 42 +``` + +### 4. Provide Storage Seeds + +```bash +# Test how different storage states affect execution +echo '{"balance_alice": 1000, "balance_bob": 500}' > storage.json +soroban-debug symbolic contract.wasm --function transfer --storage-seed storage.json +``` + +## Coverage in Scenario TOML + +When you export symbolic analysis to a scenario file with `--output`, the coverage metrics are included: + +```toml +[metadata] +max_paths = 100 +max_input_combinations = 256 +timeout_secs = 30 +generated_input_combinations = 256 +attempted_input_combinations = 47 +distinct_paths_recorded = 12 +unique_functions_reached = 3 +total_functions_available = 12 +branches_touched = 12 +duplicates_suppressed = 35 +exploration_cap_reached = false +``` + +This allows you to: +- Track coverage improvements over time +- Compare coverage between contract versions +- Ensure consistent coverage in CI/CD pipelines + +## Technical Details + +### How Coverage is Calculated + +1. **Functions reached**: Counts unique exported functions that were successfully invoked during symbolic execution +2. **Total functions**: Extracted from WASM export section using `wasmparser` +3. **Branches touched**: Conservative estimate based on distinct execution paths (each path represents β‰₯1 branch) +4. **Duplicates**: Calculated as `paths_explored - distinct_paths_recorded` + +### Limitations + +- **Function coverage** only tracks the top-level function being tested, not internal helper functions +- **Branch coverage** is an approximation - true branch coverage would require instrumenting WASM bytecode +- **Cross-contract calls** are not tracked in coverage metrics (only the target contract's functions) + +### Future Enhancements + +Potential improvements for more accurate coverage: +- Instrument WASM to track internal function calls +- Parse WASM control flow to count actual branches (if/else, br_if, etc.) +- Track basic block coverage within functions +- Integrate with DWARF debug info for source-level coverage + +## Use Cases + +### 1. Pre-Deployment Validation + +Before deploying to mainnet, ensure symbolic execution explored sufficient coverage: + +```bash +REPORT=$(soroban-debug symbolic contract.wasm --function transfer --profile deep) +COVERAGE=$(echo "$REPORT" | grep "Functions reached" | awk '{print $4}' | tr -d '()') +if (( $(echo "$COVERAGE < 50.0" | bc -l) )); then + echo "WARNING: Coverage below 50%, review before deployment" + exit 1 +fi +``` + +### 2. Regression Testing + +Compare coverage between contract versions to ensure new code is exercised: + +```bash +# Version 1.0 +soroban-debug symbolic v1.0.wasm --function transfer --output v1_scenario.toml + +# Version 2.0 +soroban-debug symbolic v2.0.wasm --function transfer --output v2_scenario.toml + +# Compare coverage metrics +diff <(grep "functions_reached" v1_scenario.toml) <(grep "functions_reached" v2_scenario.toml) +``` + +### 3. CI/CD Integration + +Add coverage thresholds to your CI pipeline: + +```yaml +# .github/workflows/symbolic-analysis.yml +- name: Symbolic Coverage Check + run: | + OUTPUT=$(soroban-debug symbolic target/wasm32-unknown-unknown/release/contract.wasm --function main --profile deep) + echo "$OUTPUT" | grep -q "Exploration hit path cap" && exit 1 + echo "$OUTPUT" | grep -q "Functions reached: 0" && exit 1 +``` + +## Best Practices + +1. **Always check coverage metrics** after symbolic execution runs +2. **Use `--profile deep`** for contracts with complex branching logic +3. **Set appropriate caps** - too low and you miss coverage, too high and you waste time +4. **Use seeds** for reproducible coverage in CI/CD +5. **Export scenarios** to track coverage history over time +6. **Combine with other analysis** - use `analyze`, `profile`, and `compare` commands for complete picture + +## Related Documentation + +- [Symbolic Execution Tutorial](../tutorials/symbolic-analysis-budgets.md) +- [Performance Optimization Guide](optimization-guide.md) +- [Feature Matrix](feature-matrix.md) diff --git a/docs/timeline-export.md b/docs/timeline-export.md index c547ee4a..817df566 100644 --- a/docs/timeline-export.md +++ b/docs/timeline-export.md @@ -36,7 +36,7 @@ The artifact is JSON with a small, versioned schema: - `schema_version`: Format version for forwards-compatible parsing. - `created_at`: RFC3339 UTC timestamp of when the file was produced. - `run`: Basic run metadata (contract path, function, args JSON, result/error, budget, events count). -- `pauses`: Best-effort pause points (currently includes entry breakpoint hits in `run`). +- `pauses`: Best-effort pause points (currently includes entry breakpoint hits in `run`, with `breakpoint_id` and `hit_count` when available). - `stack_summary`: Best-effort end-of-run call stack summary. - `deltas.storage`: Storage diff summary (added/modified/deleted keys, alerts, and truncation marker). - `warnings`: Warnings emitted during narrative construction (e.g. triggered storage alerts). diff --git a/docs/tutorials/ci-remote-debugging.md b/docs/tutorials/ci-remote-debugging.md new file mode 100644 index 00000000..aef2c348 --- /dev/null +++ b/docs/tutorials/ci-remote-debugging.md @@ -0,0 +1,77 @@ +# Tutorial: Remote Debugging in CI + +This tutorial walks you through setting up and using remote debugging for a Soroban contract running in a Continuous Integration (CI) environment. This is the typical DevOps use case for debugging failing CI tests where a local SSH tunnel isn't feasible. + +## Prerequisites + +- A GitHub Actions workflow (or similar CI) running tests. +- `soroban-debug` installed both locally and in the CI environment. +- The contract WASM artifact you want to debug. +- A secure way to access the CI runner's network, such as Tailscale, Ngrok, or an exposed port (only if heavily protected). + +## Step 1: Start the Debug Server in CI + +Configure your CI pipeline to start the debug server before running the failing tests or pause the build upon failure to allow you to attach. It is critical to protect the server with an authentication token to prevent unauthorized access. + +Add the following step to your CI workflow (e.g., in `.github/workflows/test.yml`): + +```yaml +steps: + - name: Start Debug Server + run: | + # Use a secure, randomly generated token from secrets + soroban-debug server \ + --host 0.0.0.0 \ + --port 9229 \ + --token "${{ secrets.DEBUG_TOKEN }}" & + echo $! > server.pid + # Give the server a moment to start + sleep 2 +``` + +> **Warning:** Binding to `0.0.0.0` exposes the port to all interfaces on the runner. This should only be done if the CI environment's ingress is restricted, or if used in conjunction with TLS transport hardening. Never expose unprotected debugging ports to the public internet. + +## Step 2: Configure the Remote Client + +On your local workstation, configure the remote client to connect to the CI environment. You'll need the CI runner's IP address or hostname. + +Assuming the runner is reachable at `ci-runner.internal.example.com` on your corporate VPN: + +```bash +soroban-debug remote \ + --remote ci-runner.internal.example.com:9229 \ + --token "$SOROBAN_DEBUG_TOKEN" \ + --contract ./target/wasm32-unknown-unknown/release/contract.wasm \ + --function failing_test_function \ + --args '[]' +``` + +Make sure your local `$SOROBAN_DEBUG_TOKEN` environment variable strictly matches the one stored in your CI secrets. + +## Step 3: Connect and Debug + +When the remote client connects, you can use the typical debugger commands to inspect the state and pinpoint the issue: + +1. **Set Breakpoints:** Use `break` to pause execution at critical locations before the failure. +2. **Step Through Code:** Use `step`, `next`, and `finish` to trace execution path. +3. **Inspect State:** Use `print` to view variables and `storage` to inspect the ledger data. +4. **Identify the Cause:** Observe the conditions that lead to the test failure (e.g., an unexpected panic or incorrect return value). + +## Step 4: Graceful Shutdown + +Once the debug session finishes, the CI pipeline should gracefully shut down the debug server so the job can complete cleanly and release runner resources. + +Ensure your workflow has a cleanup step: + +```yaml + - name: Cleanup Debug Server + if: always() + run: | + [ -f server.pid ] && kill $(cat server.pid) || true + wait +``` + +## Next Steps + +- Review the [Remote Debugging Guide](../remote-debugging.md) for deeper details on TLS and transport hardening, especially if your CI runners operate on untrusted networks. +- If you encounter connection issues, refer to [Remote Troubleshooting](../remote-troubleshooting.md). diff --git a/docs/tutorials/debug-auth-errors.md b/docs/tutorials/debug-auth-errors.md index c6759f20..d79eafcf 100644 --- a/docs/tutorials/debug-auth-errors.md +++ b/docs/tutorials/debug-auth-errors.md @@ -479,12 +479,12 @@ soroban-debug run \ Before deploying your contract, verify: -- [ ] All state-modifying functions check authorization -- [ ] The correct address is verified (sender, not recipient) -- [ ] Admin functions verify caller is admin -- [ ] Cross-contract calls propagate authorization -- [ ] Tests verify auth is actually enforced (don't just use `mock_all_auths()`) -- [ ] Error messages are clear about which auth failed +- [x] All state-modifying functions check authorization +- [x] The correct address is verified (sender, not recipient) +- [x] Admin functions verify caller is admin +- [x] Cross-contract calls propagate authorization +- [x] Tests verify auth is actually enforced (don't just use `mock_all_auths()`) +- [x] Error messages are clear about which auth failed ## Best Practices diff --git a/docs/tutorials/first-debug.md b/docs/tutorials/first-debug.md index 14c25cec..0bc870f5 100644 --- a/docs/tutorials/first-debug.md +++ b/docs/tutorials/first-debug.md @@ -87,7 +87,24 @@ soroban contract build Verify that your WASM file was generated at `target/wasm32-unknown-unknown/release/hello_world.wasm`. Because debug symbols are included, the file size will be significantly larger than a heavily optimized production build. -## 4. Starting the Debugger +## 4. Save Project Defaults in `.soroban-debug.toml` + +Before you start the debugger, add a project-local config file so repeated sessions keep the same defaults. Create `.soroban-debug.toml` in the project root: + +```toml +[debug] +breakpoints = ["increment"] +verbosity = 1 + +[output] +show_events = true +``` + +The debugger loads this file automatically when it starts. It is a good place to keep default breakpoints, verbosity, and output settings that you want every contributor to share. + +If you prefer debugging from VS Code, keep this file alongside a `.vscode/launch.json` and follow [Set Up the VS Code Extension](vscode-extension-setup.md) for the editor-side setup. + +## 5. Starting the Debugger Launch the debugger and pass the path to your compiled WASM file. @@ -104,7 +121,7 @@ Debug symbols loaded successfully. You are now in the interactive debugging prompt. Type `help` to see all available basic debugger commands (`run`, `step`, `next`, `break`, `print`, `storage`). -## 5. Setting Breakpoints +## 6. Setting Breakpoints Before running the contract, we need to tell the debugger where to pause execution. You can set breakpoints by function name or by file and line number. @@ -122,7 +139,7 @@ Breakpoint 1 set at src/lib.rs:13 ![Setting a Breakpoint](./images/debugger-breakpoint.png) *(Screenshot: Terminal showing the breakpoint confirmation and the interactive prompt)* -## 6. Running and Stepping Through Code +## 7. Running and Stepping Through Code To trigger the execution, we use the `invoke` command, simulating a call to the contract. @@ -146,7 +163,7 @@ Execute the current line and move to the next one using the `next` (or `n`) comm (soroban-debug) next ``` -## 7. Inspecting Storage and Variables +## 8. Inspecting Storage and Variables Now that we have stepped past line 13, the `count` variable has been initialized. Let's inspect it using the `print` command: @@ -216,4 +233,4 @@ Return value: U32(1) * **Host Environment:** The debugger runs a mock Soroban environment. State does not persist between `soroban-debugger` CLI sessions unless you export the ledger state to a JSON file. --- -*Return to the [Docs Index](../../README.md) for more tutorials.* +*Return to the [Docs Index](../index.md) for more tutorials.* diff --git a/docs/tutorials/plugin-development.md b/docs/tutorials/plugin-development.md new file mode 100644 index 00000000..f15cd4f7 --- /dev/null +++ b/docs/tutorials/plugin-development.md @@ -0,0 +1,474 @@ +# Building Your First Soroban Debugger Plugin + +This tutorial walks you through writing, building, installing, and iterating on a real debugger plugin end-to-end. By the end you will have a working **gas-spike alerter** plugin that watches every function call and prints a warning whenever CPU instruction usage exceeds a configurable threshold. + +For the complete API reference, see [Plugin API](../plugin-api.md). This tutorial focuses on the workflow, not the reference. + +## Prerequisites + +- Soroban Debugger installed and on your `$PATH` +- Rust toolchain (stable, 1.75+) +- A compiled contract WASM to test against β€” we'll use `examples/contracts/simple-token` + +--- + +## 1. Understand the Plugin Model + +Plugins are Rust `cdylib` crates. The debugger loads them at startup from `~/.soroban-debug/plugins/` using `libloading`. Each plugin: + +1. Exports a single C-ABI function `create_plugin()` that returns a boxed `InspectorPlugin` trait object. +2. Provides a `plugin.toml` manifest so the debugger knows its name, version, and capabilities before loading the shared library. +3. Receives `ExecutionEvent` callbacks during contract execution. + +``` +~/.soroban-debug/plugins/ +└── gas-spike-alerter/ + β”œβ”€β”€ plugin.toml ← manifest (read first) + └── libgas_spike_alerter.dylib ← shared library (loaded second) +``` + +The full lifecycle is: **discover β†’ validate manifest β†’ trust check β†’ load library β†’ `initialize()` β†’ receive events β†’ `shutdown()`**. + +--- + +## 2. Create the Crate + +```bash +cargo new --lib gas-spike-alerter +cd gas-spike-alerter +``` + +Open `Cargo.toml` and replace its contents: + +```toml +[package] +name = "gas-spike-alerter" +version = "0.1.0" +edition = "2021" + +[lib] +# cdylib produces a .so / .dylib / .dll that can be dynamically loaded +crate-type = ["cdylib"] + +[dependencies] +# Point at your local checkout. Adjust the path as needed. +soroban-debugger = { path = "../../.." } +``` + +> If you're developing outside the debugger repository, publish the `soroban-debugger` crate to crates.io or use a git dependency instead. + +--- + +## 3. Write the Plugin + +Replace `src/lib.rs` with the following. Read the inline comments β€” they explain every decision. + +```rust +use soroban_debugger::plugin::{ + EventContext, ExecutionEvent, InspectorPlugin, PluginCapabilities, PluginManifest, + PluginResult, +}; +use std::any::Any; + +// ── Plugin state ────────────────────────────────────────────────────────────── + +pub struct GasSpikeAlerter { + manifest: PluginManifest, + /// Warn when a single function call exceeds this many CPU instructions. + threshold: u64, + /// Running count of alerts fired this session. + alert_count: usize, +} + +impl GasSpikeAlerter { + fn new() -> Self { + Self { + manifest: PluginManifest { + name: "gas-spike-alerter".to_string(), + version: "0.1.0".to_string(), + description: "Warns when a function call exceeds a CPU-instruction threshold" + .to_string(), + author: "Your Name".to_string(), + license: Some("MIT".to_string()), + min_debugger_version: Some("0.1.0".to_string()), + capabilities: PluginCapabilities { + hooks_execution: true, + provides_commands: false, + provides_formatters: false, + // We don't carry across-reload state, so hot-reload is trivial. + supports_hot_reload: true, + }, + library: "libgas_spike_alerter.dylib".to_string(), + dependencies: vec![], + signature: None, + }, + // Read threshold from the environment; default to 500 000 instructions. + threshold: std::env::var("GAS_SPIKE_THRESHOLD") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500_000), + alert_count: 0, + } + } +} + +// ── Trait implementation ────────────────────────────────────────────────────── + +impl InspectorPlugin for GasSpikeAlerter { + fn metadata(&self) -> PluginManifest { + self.manifest.clone() + } + + fn initialize(&mut self) -> PluginResult<()> { + eprintln!( + "[gas-spike-alerter] loaded β€” threshold: {} instructions", + self.threshold + ); + Ok(()) + } + + fn shutdown(&mut self) -> PluginResult<()> { + eprintln!( + "[gas-spike-alerter] shutdown β€” {} alert(s) fired this session", + self.alert_count + ); + Ok(()) + } + + fn on_event(&mut self, event: &ExecutionEvent, _ctx: &mut EventContext) -> PluginResult<()> { + // We only care about completed function calls that carry budget information. + if let ExecutionEvent::AfterFunctionCall { + function, + cpu_instructions, + .. + } = event + { + if let Some(instructions) = cpu_instructions { + if *instructions > self.threshold { + self.alert_count += 1; + eprintln!( + "[gas-spike-alerter] ⚠️ SPIKE in '{}': {} instructions (threshold: {})", + function, instructions, self.threshold + ); + } + } + } + Ok(()) + } + + // ── Hot-reload support ──────────────────────────────────────────────────── + // We don't have state that needs preserving across a reload, so these are + // trivially implemented. + + fn supports_hot_reload(&self) -> bool { + true + } + + fn prepare_reload(&self) -> PluginResult> { + Ok(Box::new(())) + } + + fn restore_from_reload(&mut self, _state: Box) -> PluginResult<()> { + Ok(()) + } +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +/// The debugger calls this symbol to obtain a plugin instance. +/// Must be `no_mangle` and `extern "C"` to survive dynamic linking. +#[no_mangle] +pub extern "C" fn create_plugin() -> *mut dyn InspectorPlugin { + Box::into_raw(Box::new(GasSpikeAlerter::new())) +} +``` + +### What each piece does + +| Part | Purpose | +|---|---| +| `PluginManifest` | Metadata the debugger reads from the in-memory struct (after loading) and from `plugin.toml` (before loading). Keep them in sync. | +| `initialize` / `shutdown` | Lifecycle hooks β€” good for opening files, logging session summaries. | +| `on_event` | Called for every `ExecutionEvent`. Pattern-match only the variants you care about; ignore the rest. | +| `supports_hot_reload` + `prepare_reload` + `restore_from_reload` | Allow the plugin to be rebuilt and reloaded without restarting the debugger. | +| `create_plugin` | The single C-ABI symbol the loader looks for. It heap-allocates the plugin and hands ownership to the debugger. | + +--- + +## 4. Write the Manifest + +Create `plugin.toml` in the crate root (next to `Cargo.toml`): + +```toml +schema_version = "1.0.0" +name = "gas-spike-alerter" +version = "0.1.0" +description = "Warns when a function call exceeds a CPU-instruction threshold" +author = "Your Name" +license = "MIT" +min_debugger_version = "0.1.0" + +[capabilities] +hooks_execution = true +provides_commands = false +provides_formatters = false +supports_hot_reload = true + +# The filename must match what you produce on your OS: +# Linux: libgas_spike_alerter.so +# macOS: libgas_spike_alerter.dylib +# Windows: gas_spike_alerter.dll +library = "libgas_spike_alerter.dylib" + +dependencies = [] +``` + +--- + +## 5. Build the Plugin + +```bash +cargo build --release +``` + +On macOS the output is: + +``` +target/release/libgas_spike_alerter.dylib +``` + +On Linux it ends in `.so`; on Windows, `.dll`. Adjust the `library` field in `plugin.toml` (and the manifest in `src/lib.rs`) to match your platform. + +--- + +## 6. Install + +```bash +# Create the plugin directory +mkdir -p ~/.soroban-debug/plugins/gas-spike-alerter + +# Copy the shared library (adjust extension for your OS) +cp target/release/libgas_spike_alerter.dylib \ + ~/.soroban-debug/plugins/gas-spike-alerter/ + +# Copy the manifest +cp plugin.toml ~/.soroban-debug/plugins/gas-spike-alerter/ +``` + +--- + +## 7. Verify the Plugin Loads + +Run any debugger command. The plugin's `initialize` message prints to stderr: + +```bash +soroban-debugger run \ + --contract path/to/simple_token.wasm \ + --function balance \ + --args '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ"]' +``` + +Expected output (stderr): + +``` +[gas-spike-alerter] loaded β€” threshold: 500000 instructions +``` + +If you don't see this, check the [plugin loading troubleshooting](#troubleshooting) section at the end. + +--- + +## 8. Test the Alert + +Use the `mint` function, which is more computationally intensive: + +```bash +soroban-debugger run \ + --contract path/to/simple_token.wasm \ + --function mint \ + --args '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", 1000000]' +``` + +To force an alert without waiting for a real spike, lower the threshold: + +```bash +GAS_SPIKE_THRESHOLD=1000 soroban-debugger run \ + --contract path/to/simple_token.wasm \ + --function mint \ + --args '["GD726E62Z6XU6KD5J2EPOHG5NQZ5K5I5J5QZQZQZQZQZQZQZQZQZQZQ", 1000000]' +``` + +Expected stderr: + +``` +[gas-spike-alerter] loaded β€” threshold: 1000 instructions +[gas-spike-alerter] ⚠️ SPIKE in 'mint': 42731 instructions (threshold: 1000) +[gas-spike-alerter] shutdown β€” 1 alert(s) fired this session +``` + +--- + +## 9. Iterate with Hot-Reload + +Hot-reload lets you recompile and reload the plugin without restarting a long-running debugger session (e.g., in `interactive` or `repl` mode). + +### Start an interactive session + +```bash +soroban-debugger interactive \ + --contract path/to/simple_token.wasm \ + --function initialize \ + --args '["GD5DJ3...", "My Token", "MTK"]' +``` + +### Edit the plugin + +Change the alert message in `src/lib.rs` (e.g., add `[ALERT]` prefix): + +```rust +eprintln!( + "[ALERT][gas-spike-alerter] ⚠️ SPIKE in '{}': {} instructions (threshold: {})", + function, instructions, self.threshold +); +``` + +Bump the version to `"0.1.1"` in both `src/lib.rs` and `plugin.toml`. + +### Rebuild + +```bash +cargo build --release +cp target/release/libgas_spike_alerter.dylib \ + ~/.soroban-debug/plugins/gas-spike-alerter/ +cp plugin.toml ~/.soroban-debug/plugins/gas-spike-alerter/ +``` + +### Trigger the reload + +In the interactive session, run: + +``` +(debugger) plugin reload gas-spike-alerter +``` + +The debugger reports what changed: + +``` +Plugin 'gas-spike-alerter' reload changes: + Version: 0.1.0 β†’ 0.1.1 +``` + +Continue the session β€” subsequent function calls use the updated plugin immediately. + +--- + +## 10. Add a Custom Command (Optional Extension) + +To expose a `spike-summary` command that prints total alert counts on demand, extend the plugin: + +```rust +use soroban_debugger::plugin::PluginCommand; + +impl InspectorPlugin for GasSpikeAlerter { + // ... existing methods ... + + fn commands(&self) -> Vec { + vec![PluginCommand { + name: "spike-summary".to_string(), + description: "Print the number of gas-spike alerts fired this session".to_string(), + arguments: vec![], + }] + } + + fn execute_command(&mut self, command: &str, _args: &[String]) -> PluginResult { + match command { + "spike-summary" => Ok(format!( + "{} spike alert(s) fired (threshold: {} instructions)", + self.alert_count, self.threshold + )), + _ => Err(soroban_debugger::plugin::PluginError::ExecutionFailed( + format!("unknown command: {}", command), + )), + } + } +} +``` + +Update `plugin.toml`: + +```toml +[capabilities] +hooks_execution = true +provides_commands = true # ← flip this +``` + +Rebuild and reinstall, then in an interactive session: + +``` +(debugger) spike-summary +2 spike alert(s) fired (threshold: 500000 instructions) +``` + +--- + +## 11. Sign the Plugin for Enforce Mode (Optional) + +In CI environments where `SOROBAN_DEBUG_PLUGIN_TRUST_MODE=enforce` is set, unsigned plugins are blocked. To sign: + +```bash +# Generate a key pair (only once per team) +soroban-debugger plugin sign \ + --manifest plugin.toml \ + --library libgas_spike_alerter.dylib \ + --key-out team-release.key \ + --pub-out team-release.pub + +# The command appends a [signature] block to plugin.toml +``` + +Tell the debugger to trust your key: + +```bash +export SOROBAN_DEBUG_PLUGIN_ALLOWED_SIGNERS=$(cat team-release.pub) +``` + +See [Plugin API Β§ Trust Policy](../plugin-api.md#trust-policy) for the full trust model. + +--- + +## Troubleshooting + +### Plugin not loading + +- Confirm `~/.soroban-debug/plugins/gas-spike-alerter/plugin.toml` exists and is valid TOML. +- Run `soroban-debugger run ... 2>&1 | head -20` to see early stderr output. +- Make sure the `library` field in `plugin.toml` matches the actual filename on your OS. +- Verify the shared library exports `create_plugin`: + ```bash + # macOS / Linux + nm -D target/release/libgas_spike_alerter.dylib | grep create_plugin + ``` +- If trust mode is blocking the plugin, either allowlist it or relax the mode: + ```bash + SOROBAN_DEBUG_PLUGIN_TRUST_MODE=warn soroban-debugger run ... + ``` + +### `on_event` not called + +- Confirm `hooks_execution = true` in both `plugin.toml` and `PluginCapabilities` in `src/lib.rs`. +- The `AfterFunctionCall` variant only carries `cpu_instructions` when the debugger was built with budget tracking enabled. Try `--verbose` to confirm budget data is being recorded. + +### Hot-reload shows no changes + +- Verify you copied the newly built library to the plugin directory before triggering reload. +- Check that the version in `plugin.toml` was bumped β€” the change-detection diff uses the manifest. + +--- + +## What to Read Next + +- [Plugin API Reference](../plugin-api.md) β€” complete trait documentation, all `ExecutionEvent` variants, and the full manifest schema. +- [Plugin Manifest Versioning](../plugin-manifest-versioning.md) β€” how to handle breaking changes across debugger versions. +- [Plugin Failure Handling](../plugin-failure-handling.md) β€” what happens when a plugin panics or returns an error. +- [Plugin Sandbox Policy](../plugin-sandbox-policy.md) β€” resource and capability limits applied to plugins. +- [Example Logger Plugin](../../examples/plugins/example_logger/) β€” a fuller example with file I/O and multiple commands. diff --git a/docs/tutorials/scenario-runner.md b/docs/tutorials/scenario-runner.md index f873f78d..1a3b2ef6 100644 --- a/docs/tutorials/scenario-runner.md +++ b/docs/tutorials/scenario-runner.md @@ -36,10 +36,18 @@ Each step in a scenario supports the following fields: |-------|------|----------|-------------| | `name` | String | Optional | Human-readable name for the step (defaults to function name) | | `function` | String | Required | Name of the contract function to call | -| `args` | String | Optional | JSON array of arguments to pass to the function | -| `timeout_secs` | Integer | Optional | Per-step execution timeout override in seconds. `0` disables the timeout | -| `expected_return` | String | Optional | Expected return value (string comparison) | +| `args` | String | Optional | JSON array of arguments to pass to the function. Supports `{{var}}` interpolation. | +| `timeout_secs` | Integer | Optional | Per-step execution timeout override in seconds (alias: `timeout`). `0` disables the timeout | +| `expected_return` | String | Optional | Expected return value (string comparison). Supports `{{var}}` interpolation. | | `expected_storage` | Table | Optional | Map of storage keys to expected values | +| `expected_events` | Array | Optional | List of event assertions (see [Event Assertions](#event-assertions)) | +| `expected_error` | String | Optional | Expected error message substring (if the step should fail) | +| `expected_panic` | String | Optional | Expected panic message substring (if the step should panic) | +| `capture` | String | Optional | Variable name to store the return value for use in later steps | +| `tags` | Array | Optional | List of category tags for filtering (see [Scenario Tags](../scenario-tags.md)) | +| `notes` | String | Optional | Documentation note for the step | +| `skip` | Boolean | Optional | If `true`, the step is skipped during execution | +| `budget_limits` | Table | Optional | Max budget constraints (see [Budget Limits](#budget-limits)) | ### Timeout Defaults and Overrides @@ -67,6 +75,42 @@ The `expected_storage` field uses TOML table syntax: **Note**: Storage keys and values are compared as strings after trimming whitespace. +### Event Assertions + +The `expected_events` field allows you to verify contract events: + +```toml +[[steps.expected_events]] +topics = ["TOPIC_1", "TOPIC_2"] +data = "EXPECTED_DATA" +contract_id = "OPTIONAL_CONTRACT_ID" +``` + +### Budget Limits + +You can enforce resource limits on a per-step basis: + +```toml +[steps.budget_limits] +max_cpu_instructions = 1000000 +max_memory_bytes = 1048576 +``` + +### Variables and Capturing + +You can capture a return value and use it in subsequent steps: + +```toml +[[steps]] +function = "get_id" +capture = "my_id" + +[[steps]] +function = "process" +args = '["{{my_id}}", 100]' +expected_return = "{{my_id}}" +``` + ## Complete Worked Example Let's create a comprehensive 5-step scenario for the SimpleToken contract. This scenario will test initialization, minting, transfers, and balance queries. diff --git a/docs/tutorials/symbolic-analysis-budgets.md b/docs/tutorials/symbolic-analysis-budgets.md index e85af985..a19cf11c 100644 --- a/docs/tutorials/symbolic-analysis-budgets.md +++ b/docs/tutorials/symbolic-analysis-budgets.md @@ -49,6 +49,26 @@ Symbolic reports now explain whether exploration was truncated by: Generated scenario TOML files include a `[metadata]` section with the applied budget and truncation reasons, which is useful for CI artifacts and reproducible investigations. +## Interpreting the Exploration Report + +When symbolic analysis completes, the debugger outputs an exploration report summarizing the execution paths discovered and the potential vulnerabilities found. + +A typical report includes: + +1. **Exploration Summary:** The total number of paths analyzed, inputs generated, and whether the exploration was truncated due to budget limits. +2. **Vulnerability Findings:** A list of critical issues detected, such as panics, out-of-bounds access, or unhandled errors. Each finding points to the specific code location and the input combination that triggers it. +3. **Coverage Metrics:** An overview of which contract branches were exercised by the generated paths. + +If the report indicates truncation (e.g., `Truncation Reason: timeout`), it means the analysis did not exhaustively search all possible states. To gain more confidence, you may need to run it again with a `deep` profile or a higher `--timeout`. + +## Acting on Findings + +Once you have identified issues in the report, take the following steps to resolve them: + +1. **Reproduce the Issue:** Use the generated scenario TOML files to run the exact inputs that caused the failure. You can replay these scenarios using the `soroban-debug run --scenario` command to step through the execution interactively. +2. **Add Defensive Checks:** If a panic or vulnerability is triggered by an unexpected input, add explicit assertions or handle the edge case gracefully in your Rust code. +3. **Refine Analysis Budgets:** If the exploration hits the `path-cap` before reaching critical code paths, consider increasing the budget caps or restricting the input space (using constraints) to focus the engine on specific contract states. +4. **Iterate:** After applying your fixes, rerun the symbolic analysis to confirm the vulnerability is resolved and no new regressions were introduced. ## JSON schema Machine-readable symbolic reports are emitted with `--format json` and use the shared command envelope: diff --git a/docs/tutorials/understanding-budget.md b/docs/tutorials/understanding-budget.md index cab4a994..0c154bd5 100644 --- a/docs/tutorials/understanding-budget.md +++ b/docs/tutorials/understanding-budget.md @@ -344,6 +344,39 @@ soroban-debug compare before.json after.json Look for budget improvements in the diff. +## Tracking Budget Trends and Regressions + +The Soroban debugger can analyze historical budget usage to detect performance regressions automatically over time. This is particularly useful in CI environments to prevent inefficient code from being merged. + +### The `--budget-trend` Flag + +By running your execution with the `--budget-trend` flag, the debugger analyzes previous execution records to visualize how your contract's resource usage has changed over recent runs: + +```bash +soroban-debug run \ + --contract contract.wasm \ + --function process \ + --budget-trend +``` + +**Example Output:** +``` +Budget Trend (last 5 runs): + Run 1: 15,234 CPU + Run 2: 15,234 CPU + Run 3: 15,300 CPU + Run 4: 8,456,789 CPU ⚠ REGRESSION DETECTED + Run 5: 8,456,789 CPU + +Analysis: CPU usage increased by 55,419% between Run 3 and Run 4. +``` + +### History-Based Regression Detection + +The debugger maintains a baseline of your contract's budget consumption. If a new code change causes a statistically significant increase in CPU instructions or memory usage compared to the baseline, the debugger will flag it as a **history-based regression**. + +You can configure the sensitivity of this detection in your project's configuration or via CLI flags to catch inefficiencies early in your development lifecycle. + ## Real-World Budget Limits **Current Soroban Mainnet Limits (as of 2024):** diff --git a/docs/tutorials/vscode-extension-setup.md b/docs/tutorials/vscode-extension-setup.md new file mode 100644 index 00000000..e5edfc45 --- /dev/null +++ b/docs/tutorials/vscode-extension-setup.md @@ -0,0 +1,114 @@ +# Tutorial: Set Up the VS Code Extension + +This tutorial walks through the full VS Code debugger setup: install the extension, create a `launch.json`, place breakpoints in Rust source, and start a Soroban debug session without leaving the editor. + +## Prerequisites + +- VS Code installed locally. +- A Soroban contract workspace with a compiled WASM artifact that still contains debug symbols. +- The `soroban-debug` CLI available either on your `PATH` or built in this repository at `target/debug/soroban-debug`. + +If you have not built a debug-friendly contract yet, start with [First Debug Session](first-debug.md) and return here once you have a `.wasm` file under `target/wasm32-unknown-unknown/release/`. + +## 1. Install the extension + +The repository currently documents a local install flow based on a packaged VSIX. + +Build the extension from the repository: + +```bash +cd extensions/vscode +npm install +npm run build +vsce package +``` + +This produces a file named `soroban-debugger-.vsix`. + +Install that VSIX in VS Code: + +1. Open VS Code. +2. Open the Extensions view. +3. Run `Extensions: Install from VSIX...` from the Command Palette. +4. Select the generated `soroban-debugger-.vsix` file. + +For extension internals and the full argument reference, see the [VS Code extension README](../../extensions/vscode/README.md). + +## 2. Create `.vscode/launch.json` + +Create a `.vscode/launch.json` file in your contract workspace: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Soroban: Debug hello_world", + "type": "soroban", + "request": "launch", + "contractPath": "${workspaceFolder}/target/wasm32-unknown-unknown/release/hello_world.wasm", + "snapshotPath": "${workspaceFolder}/snapshot.json", + "entrypoint": "increment", + "args": [], + "trace": false + } + ] +} +``` + +Adjust these fields for your project: + +- `contractPath`: compiled contract you want to debug. +- `entrypoint`: exported Soroban function to invoke. +- `args`: JSON-compatible argument list passed to that entrypoint. +- `binaryPath`: add this only when the adapter cannot find `soroban-debug` on your `PATH`, for example when you want VS Code to use a specific local build of the CLI. + +`.soroban-debug.toml` complements `launch.json`; use the TOML file for shared debugger defaults such as breakpoints or output behavior, and `launch.json` for VS Code session wiring. + +## 3. Validate the launch configuration + +Before starting a session, run the built-in preflight check: + +1. Open the Command Palette. +2. Run `Soroban: Run Launch Preflight Check`. +3. Pick the Soroban launch configuration you just created. + +Use this whenever you change `contractPath`, `binaryPath`, or argument structure. The preflight check catches invalid launch settings before the adapter starts. + +## 4. Set breakpoints in Rust source + +Open the Rust file that corresponds to the contract you want to debug, for example `contracts/hello_world/src/lib.rs`. + +Set breakpoints in the editor gutter on executable lines such as: + +- The first line inside `increment`. +- The storage write line where state changes are committed. + +You can confirm the breakpoints in the **Run and Debug** panel before you launch the session. + +If a breakpoint stays gray or shows as unverified: + +1. Open the file where the breakpoint is set. +2. Run `Soroban: Diagnose Source Maps for Current File`. +3. Move the breakpoint to the nearest executable statement if the diagnostic reports a source-mapping gap. + +## 5. Start the session and inspect state + +Start the `Soroban: Debug hello_world` configuration from the **Run and Debug** view. + +When execution stops at a breakpoint: + +- Use `F10`, `F11`, and `Shift+F11` for stepping. +- Inspect storage and locals in the **Variables** panel. +- Review the call stack in the **Call Stack** panel. +- Watch adapter output in the **Debug Console** if you enabled `"trace": true`. + +## 6. Keep the setup repeatable + +Store editor-specific session settings in `.vscode/launch.json`, and keep repo-wide debugger defaults in `.soroban-debug.toml`. That split keeps the VS Code adapter configuration explicit while still giving new contributors sensible defaults when they start with the CLI. + +Next steps: + +- [First Debug Session](first-debug.md) for the CLI-first walkthrough. +- [Breakpoints Reference](../breakpoints.md) for source and function breakpoint behavior. +- [VS Code extension README](../../extensions/vscode/README.md) for the full launch/attach schema. diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 3dcb7fff..c57db351 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -66,6 +66,8 @@ The extension will be published to the VS Code Marketplace. Once available, sear ## Quick Start +For an end-to-end setup walkthrough, including extension installation, `.vscode/launch.json`, and first breakpoints, see [docs/tutorials/vscode-extension-setup.md](../../docs/tutorials/vscode-extension-setup.md). + ### 1. Create a Debug Configuration Add the following to your project's `.vscode/launch.json`: diff --git a/extensions/vscode/src/cli/debuggerProcess.ts b/extensions/vscode/src/cli/debuggerProcess.ts index b1ee692d..5bd115d9 100644 --- a/extensions/vscode/src/cli/debuggerProcess.ts +++ b/extensions/vscode/src/cli/debuggerProcess.ts @@ -1247,7 +1247,7 @@ export async function validateLaunchConfig( const issues: LaunchPreflightIssue[] = []; const resolvedBinaryPath = resolveDebuggerBinaryPath(config); - if (!looksLikeVariableReference(resolvedBinaryPath)) { + if (config.spawnServer !== false && !looksLikeVariableReference(resolvedBinaryPath)) { pushFileIssue( issues, "binaryPath", diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 76ff3c2b..2f0a5491 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -32,7 +32,7 @@ class SorobanDebugConfigurationProvider return this.createDefaultLaunchConfig(folder) } - if (config.type !== 'soroban' || config.request !== 'launch') { + if (config.type !== 'soroban' || (config.request !== 'launch' && config.request !== 'attach')) { return config } @@ -49,6 +49,11 @@ class SorobanDebugConfigurationProvider config.snapshotPath = config.snapshotPath ?? settings.get('defaultSnapshotPath') + if (config.request === 'attach') { + config.spawnServer = false + config.host = config.host ?? '127.0.0.1' + } + const preflight = await validateLaunchConfig(config) if (preflight.ok) { return config diff --git a/man/man1/soroban-debug-plugin-inspect.1 b/man/man1/soroban-debug-plugin-inspect.1 new file mode 100644 index 00000000..0bbf3a6c --- /dev/null +++ b/man/man1/soroban-debug-plugin-inspect.1 @@ -0,0 +1,29 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH plugin-inspect 1 "plugin-inspect " +.SH NAME +plugin\-inspect \- Inspect a specific plugin\*(Aqs capabilities and metadata +.SH SYNOPSIS +\fBplugin\-inspect\fR [\fB\-\-format\fR] [\fB\-h\fR|\fB\-\-help\fR] <\fINAME\fR> +.SH DESCRIPTION +Inspect a specific plugin\*(Aqs capabilities and metadata +.SH OPTIONS +.TP +\fB\-\-format\fR \fI\fR [default: pretty] +Output format (pretty, json) +.br + +.br +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +pretty +.IP \(bu 2 +json +.RE +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fINAME\fR> +Name of the plugin to inspect diff --git a/man/man1/soroban-debug-plugin-trust-report.1 b/man/man1/soroban-debug-plugin-trust-report.1 new file mode 100644 index 00000000..aa7b9abf --- /dev/null +++ b/man/man1/soroban-debug-plugin-trust-report.1 @@ -0,0 +1,26 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH plugin-trust-report 1 "plugin-trust-report " +.SH NAME +plugin\-trust\-report \- Generate a trust and security report for all loaded plugins +.SH SYNOPSIS +\fBplugin\-trust\-report\fR [\fB\-\-format\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Generate a trust and security report for all loaded plugins +.SH OPTIONS +.TP +\fB\-\-format\fR \fI\fR [default: pretty] +Output format (pretty, json) +.br + +.br +\fIPossible values:\fR +.RS 14 +.IP \(bu 2 +pretty +.IP \(bu 2 +json +.RE +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/man1/soroban-debug-remote.1 b/man/man1/soroban-debug-remote.1 index 7d9f45a9..15cd00ba 100644 --- a/man/man1/soroban-debug-remote.1 +++ b/man/man1/soroban-debug-remote.1 @@ -4,6 +4,7 @@ .SH NAME remote \- Connect to remote debug server .SH SYNOPSIS +\fBremote\fR <\fB\-r\fR|\fB\-\-remote\fR> [\fB\-t\fR|\fB\-\-token\fR] [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-tls\-ca\fR] [\fB\-\-session\-label\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-\-connect\-timeout\-ms\fR] [\fB\-\-timeout\-ms\fR] [\fB\-\-inspect\-timeout\-ms\fR] [\fB\-\-storage\-timeout\-ms\fR] [\fB\-\-retry\-attempts\fR] [\fB\-\-retry\-base\-delay\-ms\fR] [\fB\-\-retry\-max\-delay\-ms\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIremote and server\fR] \fBremote\fR <\fB\-r\fR|\fB\-\-remote\fR> [\fB\-t\fR|\fB\-\-token\fR] [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-tls\-ca\fR] [\fB\-\-session\-label\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-\-connect\-timeout\-ms\fR] [\fB\-\-timeout\-ms\fR] [\fB\-\-inspect\-timeout\-ms\fR] [\fB\-\-storage\-timeout\-ms\fR] [\fB\-\-retry\-attempts\fR] [\fB\-\-retry\-base\-delay\-ms\fR] [\fB\-\-retry\-max\-delay\-ms\fR] [\fB\-\-format\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIremote and server\fR] .SH DESCRIPTION Connect to remote debug server diff --git a/man/man1/soroban-debug.1 b/man/man1/soroban-debug.1 index 8e406a46..f6fa1a2d 100644 --- a/man/man1/soroban-debug.1 +++ b/man/man1/soroban-debug.1 @@ -111,6 +111,12 @@ Generate shell completion scripts soroban\-debug\-history\-prune(1) Prune or compact run history according to a retention policy .TP +soroban\-debug\-plugin\-trust\-report(1) +Generate a trust and security report for all loaded plugins +.TP +soroban\-debug\-plugin\-inspect(1) +Inspect a specific plugin\*(Aqs capabilities and metadata +.TP soroban\-debug\-doctor(1) Report runtime health and diagnostics for troubleshooting .TP diff --git a/src/analyzer/symbolic.rs b/src/analyzer/symbolic.rs index 1db1d003..505c479a 100644 --- a/src/analyzer/symbolic.rs +++ b/src/analyzer/symbolic.rs @@ -15,6 +15,8 @@ pub struct PathResult { pub return_value: Option, pub panic: Option, pub path_decisions: Vec, + pub severity: String, + pub rule_mappings: Vec, } #[derive(Debug, Clone, Serialize)] @@ -26,7 +28,7 @@ pub struct SymbolicReport { pub metadata: SymbolicReportMetadata, } -#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)] pub struct SymbolicConfig { pub max_paths: usize, pub max_input_combinations: usize, @@ -41,6 +43,7 @@ pub struct SymbolicConfig { /// This allows testing how different storage states affect contract behavior. /// The storage is a map of key-value pairs. pub storage_seed: Option, + pub storage_read_pressure_threshold: Option, } impl Default for SymbolicConfig { @@ -59,6 +62,7 @@ impl SymbolicConfig { max_depth: 3, seed: None, storage_seed: None, + storage_read_pressure_threshold: Some(0.8), } } pub const fn fast() -> Self { @@ -70,6 +74,7 @@ impl SymbolicConfig { max_depth: 2, seed: None, storage_seed: None, + storage_read_pressure_threshold: Some(0.8), } } @@ -86,6 +91,7 @@ impl SymbolicConfig { max_depth: 5, seed: None, storage_seed: None, + storage_read_pressure_threshold: Some(0.8), } } } @@ -144,6 +150,16 @@ pub struct SymbolicReportMetadata { /// `--replay` (or `--seed`) on a subsequent run to reproduce the identical /// exploration order. pub seed: Option, + /// Coverage metrics: unique functions reached during symbolic exploration. + pub unique_functions_reached: usize, + /// Coverage metrics: total functions available in the WASM module. + pub total_functions_available: usize, + /// Coverage metrics: approximate branch coverage indicator (branches touched). + pub branches_touched: usize, + /// Coverage metrics: duplicate input combinations suppressed. + pub duplicates_suppressed: usize, + /// Coverage metrics: whether the exploration hit the path cap. + pub exploration_cap_reached: bool, pub coverage_fraction: f32, pub uncovered_regions: Vec, } @@ -192,12 +208,73 @@ impl SymbolicAnalyzer { Self } + + + fn grade_path( + outcome: &std::result::Result, + path_decisions: &[crate::server::protocol::DynamicTraceEvent], + config: &SymbolicConfig, + ) -> (String, Vec) { + use crate::server::protocol::DynamicTraceEventKind; + let mut severity = "Informational".to_string(); + let mut rules = Vec::new(); + + if let Err(err_str) = outcome { + severity = "Suspicious".to_string(); + rules.push("ERR-001: Execution Panic".to_string()); + + // Heuristic for high severity arithmetic issues + if err_str.contains("Arithmetic") + || err_str.contains("overflow") + || err_str.contains("underflow") + { + severity = "Release-Blocking".to_string(); + rules.push("SEC-002: Arithmetic Safety Violation".to_string()); + } + } + + // Check for storage read pressure + let storage_reads = path_decisions + .iter() + .filter(|e| matches!(e.kind, DynamicTraceEventKind::StorageRead)) + .count(); + if let Some(threshold) = config.storage_read_pressure_threshold { + if storage_reads as f64 > threshold { + severity = if severity == "Informational" { + "Suspicious".to_string() + } else { + severity + }; + rules.push("PERF-001: High Storage Read Pressure".to_string()); + } + } + + // Check for missing authorization on sensitive paths + let has_auth = path_decisions + .iter() + .any(|e| matches!(e.kind, DynamicTraceEventKind::Authorization)); + let has_write = path_decisions + .iter() + .any(|e| matches!(e.kind, DynamicTraceEventKind::StorageWrite)); + if has_write && !has_auth { + severity = if severity == "Release-Blocking" { + severity + } else { + "Suspicious".to_string() + }; + rules.push("SEC-003: Unauthenticated Storage Write".to_string()); + } + + (severity, rules) + } + fn record_outcome( report: &mut SymbolicReport, seen_inputs: &mut HashSet, inputs: &str, outcome: std::result::Result, path_decisions: Vec, + config: &SymbolicConfig, ) { // Keep distinct paths even when outputs/errors are identical. // Only dedupe when the exact same input set is re-encountered. @@ -205,12 +282,16 @@ impl SymbolicAnalyzer { return; } + let (severity, rule_mappings) = Self::grade_path(&outcome, &path_decisions, config); + match outcome { Ok(val) => report.paths.push(PathResult { inputs: inputs.to_string(), return_value: Some(val), panic: None, path_decisions, + severity, + rule_mappings, }), Err(err_str) => { report.panics_found += 1; @@ -219,6 +300,8 @@ impl SymbolicAnalyzer { return_value: None, panic: Some(err_str), path_decisions, + severity, + rule_mappings, }); } } @@ -251,6 +334,10 @@ impl SymbolicAnalyzer { } let deadline = Instant::now(); + // Extract all available functions for coverage analysis + let all_functions = crate::utils::wasm::parse_functions(wasm).unwrap_or_default(); + let total_functions = all_functions.len(); + let mut report = SymbolicReport { function: function.to_string(), paths_explored: 0, @@ -266,16 +353,23 @@ impl SymbolicAnalyzer { truncated_by_timeout: false, truncation_reasons: Vec::new(), seed: config.seed, + unique_functions_reached: 0, + total_functions_available: total_functions, + branches_touched: 0, + duplicates_suppressed: 0, + exploration_cap_reached: false, coverage_fraction: 0.0, uncovered_regions: Vec::new(), }, }; let mut seen_inputs = HashSet::new(); + let mut reached_functions = HashSet::new(); for args_json in &generated_inputs.combinations { if report.paths_explored >= config.max_paths { report.metadata.truncated_by_path_cap = true; + report.metadata.exploration_cap_reached = true; break; } @@ -318,23 +412,35 @@ impl SymbolicAnalyzer { match executor_res { Ok(val) => { - Self::record_outcome(&mut report, &mut seen_inputs, args_json, Ok(val), trace); + Self::record_outcome(&mut report, &mut seen_inputs, args_json, Ok(val), trace, config); } Err(err) => { + // Track the target function as reached + reached_functions.insert(function.to_string()); Self::record_outcome( &mut report, &mut seen_inputs, args_json, Err(err.to_string()), trace, + config, ); } } + report.paths_explored += 1; } report.metadata.attempted_input_combinations = report.paths_explored; report.metadata.distinct_paths_recorded = report.paths.len(); + report.metadata.unique_functions_reached = reached_functions.len(); + // Duplicates = total attempts - distinct paths recorded + report.metadata.duplicates_suppressed = report.paths_explored.saturating_sub(report.paths.len()); + + // Estimate branches touched: each distinct path represents at least one branch decision + // This is a conservative estimate - in reality, each path may touch multiple branches + report.metadata.branches_touched = report.paths.len(); + if report.metadata.truncated_by_input_cap { report.metadata.truncation_reasons.push(format!( "input combination cap reached at {} generated combinations", @@ -769,6 +875,39 @@ impl SymbolicAnalyzer { report.metadata.truncated_by_timeout ) .unwrap(); + + // Add coverage metrics to TOML + writeln!( + toml, + "unique_functions_reached = {}", + report.metadata.unique_functions_reached + ) + .unwrap(); + writeln!( + toml, + "total_functions_available = {}", + report.metadata.total_functions_available + ) + .unwrap(); + writeln!( + toml, + "branches_touched = {}", + report.metadata.branches_touched + ) + .unwrap(); + writeln!( + toml, + "duplicates_suppressed = {}", + report.metadata.duplicates_suppressed + ) + .unwrap(); + writeln!( + toml, + "exploration_cap_reached = {}", + report.metadata.exploration_cap_reached + ) + .unwrap(); + match report.metadata.seed { Some(seed) => writeln!(toml, "seed = {}", seed).unwrap(), None => writeln!( @@ -913,6 +1052,11 @@ mod tests { truncated_by_timeout: false, truncation_reasons: Vec::new(), seed: None, + unique_functions_reached: 0, + total_functions_available: 0, + branches_touched: 0, + duplicates_suppressed: 0, + exploration_cap_reached: false, coverage_fraction: 0.0, uncovered_regions: Vec::new(), }, @@ -957,6 +1101,11 @@ mod tests { truncated_by_timeout: false, truncation_reasons: Vec::new(), seed: None, + unique_functions_reached: 0, + total_functions_available: 0, + branches_touched: 0, + duplicates_suppressed: 0, + exploration_cap_reached: false, coverage_fraction: 0.0, uncovered_regions: Vec::new(), }, @@ -1015,6 +1164,7 @@ mod tests { max_depth: 3, seed: None, storage_seed: None, + storage_read_pressure_threshold: Some(0.8), }; let report = analyzer @@ -1070,6 +1220,7 @@ mod tests { max_depth: 3, seed: Some(99), storage_seed: None, + storage_read_pressure_threshold: Some(0.8), }; let config_b = SymbolicConfig { seed: Some(99), @@ -1104,6 +1255,7 @@ mod tests { max_depth: 3, seed: None, storage_seed: None, + storage_read_pressure_threshold: Some(0.8), }; let report = analyzer @@ -1137,6 +1289,11 @@ mod tests { "input combination cap reached at 64 generated combinations".to_string(), ], seed: None, + unique_functions_reached: 1, + total_functions_available: 5, + branches_touched: 1, + duplicates_suppressed: 0, + exploration_cap_reached: false, coverage_fraction: 0.0, uncovered_regions: Vec::new(), }, @@ -1225,6 +1382,7 @@ mod tests { max_depth: 3, seed: None, storage_seed: Some(r#"{"counter": 100}"#.to_string()), + storage_read_pressure_threshold: Some(0.8), }; // The test verifies that the config accepts a storage seed. @@ -1240,6 +1398,109 @@ mod tests { } #[test] + fn analyze_with_config_tracks_coverage_metrics() { + let analyzer = SymbolicAnalyzer::new(); + let wasm = wasm_with_import_and_exported_local(); + let config = SymbolicConfig { + max_paths: 10, + max_input_combinations: 36, + timeout_secs: 30, + max_breadth: 5, + max_depth: 3, + seed: None, + storage_seed: None, + }; + + let report = analyzer + .analyze_with_config(&wasm, "entry", &config) + .expect("symbolic analysis should complete"); + + // Verify coverage metrics are populated + assert!( + report.metadata.total_functions_available > 0, + "Should detect available functions" + ); + assert!( + report.metadata.unique_functions_reached > 0, + "Should track reached functions" + ); + assert!( + report.metadata.unique_functions_reached <= report.metadata.total_functions_available, + "Reached functions cannot exceed available functions" + ); + assert!( + report.metadata.branches_touched > 0, + "Should estimate branches touched" + ); + // Branches touched should equal distinct paths recorded + assert_eq!( + report.metadata.branches_touched, + report.metadata.distinct_paths_recorded, + "Branches touched should equal distinct paths" + ); + } + + #[test] + fn analyze_with_config_tracks_duplicates() { + let analyzer = SymbolicAnalyzer::new(); + let wasm = wasm_with_import_and_exported_local(); + let config = SymbolicConfig { + max_paths: 100, + max_input_combinations: 10, // Small cap to force duplicates + timeout_secs: 30, + max_breadth: 3, + max_depth: 2, + seed: None, + storage_seed: None, + }; + + let report = analyzer + .analyze_with_config(&wasm, "entry", &config) + .expect("symbolic analysis should complete"); + + // Verify duplicate tracking + assert!( + report.paths_explored >= report.metadata.distinct_paths_recorded, + "Explored paths should be >= distinct paths" + ); + let calculated_duplicates = + report.paths_explored.saturating_sub(report.paths.len()); + assert_eq!( + report.metadata.duplicates_suppressed, calculated_duplicates, + "Duplicates should match calculated value" + ); + } + + #[test] + fn analyze_with_config_sets_exploration_cap_flag() { + let analyzer = SymbolicAnalyzer::new(); + let wasm = wasm_with_import_and_exported_local(); + let config = SymbolicConfig { + max_paths: 2, // Very low cap to force hitting the limit + max_input_combinations: 36, + timeout_secs: 30, + max_breadth: 5, + max_depth: 3, + seed: None, + storage_seed: None, + }; + + let report = analyzer + .analyze_with_config(&wasm, "entry", &config) + .expect("symbolic analysis should complete"); + + assert!( + report.metadata.exploration_cap_reached, + "Should flag when path cap is reached" + ); + assert!( + report.metadata.truncated_by_path_cap, + "Should be truncated by path cap" + ); + assert_eq!( + report.paths_explored, 2, + "Should stop at path cap" + ); fn test_generate_seeds_complex_types() { let analyzer = SymbolicAnalyzer; let config = SymbolicConfig::default(); diff --git a/src/cli/args.rs b/src/cli/args.rs index cd1a88af..f33f0291 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -260,6 +260,14 @@ pub enum Commands { #[command(subcommand_help_heading = "Developer Utilities")] HistoryPrune(HistoryPruneArgs), + /// Generate a trust and security report for all loaded plugins + #[command(subcommand_help_heading = "Developer Utilities")] + PluginTrustReport(PluginTrustReportArgs), + + /// Inspect a specific plugin's capabilities and metadata + #[command(subcommand_help_heading = "Developer Utilities")] + PluginInspect(PluginInspectArgs), + /// Report runtime health and diagnostics for troubleshooting Doctor(DoctorArgs), @@ -1564,3 +1572,20 @@ pub struct DoctorArgs { #[arg(long, value_name = "FILE")] pub vscode_manifest: Option, } + +#[derive(Parser, Debug, Clone)] +pub struct PluginTrustReportArgs { + /// Output format (pretty, json) + #[arg(long, value_enum, default_value = "pretty")] + pub format: OutputFormat, +} + +#[derive(Parser, Debug, Clone)] +pub struct PluginInspectArgs { + /// Name of the plugin to inspect + pub name: String, + + /// Output format (pretty, json) + #[arg(long, value_enum, default_value = "pretty")] + pub format: OutputFormat, +} diff --git a/src/cli/commands.rs b/src/cli/commands.rs index e0c02054..0cf3e60a 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1,3485 +1,3485 @@ -use crate::analyzer::symbolic::SymbolicConfig; -use crate::analyzer::upgrade::{CompatibilityReport, ExecutionDiff, UpgradeAnalyzer}; -use crate::analyzer::{ - security::SecurityAnalyzer, - symbolic::{build_replay_bundle, SymbolicAnalyzer}, -}; -use crate::cli::args::{ - AnalyzeArgs, CompareArgs, HistoryPruneArgs, InspectArgs, InteractiveArgs, OptimizeArgs, - OutputFormat, ProfileArgs, RemoteAction, RemoteArgs, ReplArgs, ReplayArgs, RunArgs, - ScenarioArgs, ServerArgs, SymbolicArgs, SymbolicProfile, TuiArgs, UpgradeCheckArgs, Verbosity, -}; -use crate::cli::output::write_json_pretty_file; -use crate::debugger::engine::DebuggerEngine; -use crate::debugger::instruction_pointer::StepMode; -use crate::debugger::timeline::{ - TimelineDeltas, TimelineExport, TimelinePausePoint, TimelineRunInfo, TimelineStorageDelta, - TimelineWarning, TIMELINE_EXPORT_SCHEMA_VERSION, -}; -use crate::history::{HistoryManager, RunHistory}; -use crate::inspector::events::{ContractEvent, EventInspector}; -use crate::logging; -use crate::output::OutputWriter; -use crate::repeat::RepeatRunner; -use crate::repl::ReplConfig; -use crate::runtime::executor::ContractExecutor; -use crate::simulator::SnapshotLoader; -use crate::ui::formatter::Formatter; -use crate::ui::{run_dashboard, DebuggerUI}; -use crate::{DebuggerError, Result}; -use miette::WrapErr; -use std::fs; -use std::path::PathBuf; - -fn print_info(message: impl AsRef) { - if !Formatter::is_quiet() { - println!("{}", Formatter::info(message)); - } -} - -fn print_success(message: impl AsRef) { - if !Formatter::is_quiet() { - println!("{}", Formatter::success(message)); - } -} - -fn print_warning(message: impl AsRef) { - if !Formatter::is_quiet() { - println!("{}", Formatter::warning(message)); - } -} - -/// Print the final contract return value β€” always shown regardless of verbosity. -fn print_result(message: impl AsRef) { - if !Formatter::is_quiet() { - println!("{}", Formatter::success(message)); - } -} - -/// Print verbose-only detail β€” only shown when --verbose is active. -fn print_verbose(message: impl AsRef) { - if Formatter::is_verbose() { - println!("{}", Formatter::info(message)); - } -} - -fn budget_trend_stats_or_err(records: &[RunHistory]) -> Result { - crate::history::budget_trend_stats(records).ok_or_else(|| { - DebuggerError::ExecutionError( - "Failed to compute budget trend statistics for the selected dataset".to_string(), - ) - .into() - }) -} - -#[derive(serde::Serialize)] -struct DynamicAnalysisMetadata { - function: String, - args: Option, - result: Option, - trace_entries: usize, -} - -#[derive(serde::Serialize)] -struct AnalyzeCommandOutput { - findings: Vec, - /// Rule metadata keyed by rule id (#1272). Lets downstream tools resolve a - /// finding's `rule_id` to stable id/name/severity/category/remediation - /// fields for filtering. A BTreeMap keeps the JSON ordering deterministic. - rules: std::collections::BTreeMap, - dynamic_analysis: Option, - warnings: Vec, - suppressed_count: usize, -} - -#[derive(serde::Serialize)] -struct SourceMapDiagnosticsCommandOutput { - contract: String, - source_map: crate::debugger::source_map::SourceMapInspectionReport, -} - -fn render_symbolic_report(report: &crate::analyzer::symbolic::SymbolicReport) -> String { - let mut lines = vec![ - format!("Function: {}", report.function), - format!("Paths explored: {}", report.paths_explored), - format!("Panics found: {}", report.panics_found), - format!( - "Replay token: {}", - report - .metadata - .seed - .map(|seed| seed.to_string()) - .unwrap_or_else(|| "none".to_string()) - ), - format!( - "Budget: path_cap={}, input_combination_cap={}, timeout={}s", - report.metadata.config.max_paths, - report.metadata.config.max_input_combinations, - report.metadata.config.timeout_secs - ), - format!( - "Input combinations: generated={}, attempted={}, distinct_paths={}", - report.metadata.generated_input_combinations, - report.metadata.attempted_input_combinations, - report.metadata.distinct_paths_recorded - ), - format!( - "Coverage: {:.1}% (explored branch/function coverage)", - report.metadata.coverage_fraction * 100.0 - ), - ]; - - if !report.metadata.uncovered_regions.is_empty() { - lines.push(format!( - "Uncovered regions: {}", - report.metadata.uncovered_regions.join(", ") - )); - } - - if report.metadata.truncation_reasons.is_empty() { - lines.push("Truncation: none".to_string()); - } else { - lines.push(format!( - "Truncation: {}", - report.metadata.truncation_reasons.join("; ") - )); - } - - if report.paths.is_empty() { - lines.push("No distinct execution paths were discovered.".to_string()); - return lines.join("\n"); - } - - lines.push(String::new()); - lines.push("Distinct paths:".to_string()); - - for (idx, path) in report.paths.iter().enumerate() { - let outcome = match (&path.return_value, &path.panic) { - (Some(value), _) => format!("return {}", value), - (_, Some(panic)) => format!("panic {}", panic), - _ => "unknown".to_string(), - }; - lines.push(format!( - " {}. inputs={} -> {}", - idx + 1, - path.inputs, - outcome - )); - } - - lines.join("\n") -} - -fn symbolic_profile_config(profile: SymbolicProfile) -> SymbolicConfig { - match profile { - SymbolicProfile::Fast => SymbolicConfig::fast(), - SymbolicProfile::Balanced => SymbolicConfig::balanced(), - SymbolicProfile::Deep => SymbolicConfig::deep(), - } -} - -fn symbolic_config_from_args(args: &SymbolicArgs) -> Result { - let mut config = symbolic_profile_config(args.profile); - if let Some(path_cap) = args.path_cap { - config.max_paths = path_cap; - } - if let Some(input_cap) = args.input_combination_cap { - config.max_input_combinations = input_cap; - } - if let Some(max_breadth) = args.max_breadth { - config.max_breadth = max_breadth; - } - if let Some(timeout) = args.timeout { - config.timeout_secs = timeout; - } - config.seed = args.seed.or(args.replay); - if let Some(storage_seed_path) = &args.storage_seed { - config.storage_seed = Some(fs::read_to_string(storage_seed_path).map_err(|e| { - DebuggerError::FileError(format!( - "Failed to read storage seed file {:?}: {}", - storage_seed_path, e - )) - })?); - } - - Ok(config) -} - -/// Convert MinSeverity enum to analyzer Severity enum. -fn convert_min_severity(value: crate::cli::args::MinSeverity) -> crate::analyzer::security::Severity { - match value { - crate::cli::args::MinSeverity::Low => crate::analyzer::security::Severity::Low, - crate::cli::args::MinSeverity::Medium => crate::analyzer::security::Severity::Medium, - crate::cli::args::MinSeverity::High => crate::analyzer::security::Severity::High, - } -} - -/// Find the closest matching rule IDs using Levenshtein distance. -fn suggest_rule_ids(unknown: &str, known_rules: &[String], max_distance: usize) -> Vec { - use std::cmp; - - // Calculate Levenshtein distance between two strings - let levenshtein = |a: &str, b: &str| { - let a_len = a.len(); - let b_len = b.len(); - let mut matrix = vec![vec![0; b_len + 1]; a_len + 1]; - - for i in 0..=a_len { - matrix[i][0] = i; - } - for j in 0..=b_len { - matrix[0][j] = j; - } - - for (i, a_char) in a.chars().enumerate() { - for (j, b_char) in b.chars().enumerate() { - let cost = if a_char == b_char { 0 } else { 1 }; - matrix[i + 1][j + 1] = cmp::min( - cmp::min( - matrix[i][j + 1] + 1, // deletion - matrix[i + 1][j] + 1, // insertion - ), - matrix[i][j] + cost, // substitution - ); - } - } - matrix[a_len][b_len] - }; - - let mut suggestions: Vec<_> = known_rules - .iter() - .map(|rule| { - let distance = levenshtein(&unknown.to_lowercase(), &rule.to_lowercase()); - (distance, rule.clone()) - }) - .filter(|(distance, _)| *distance <= max_distance) - .collect(); - - suggestions.sort_by_key(|(distance, _)| *distance); - suggestions.into_iter().map(|(_, rule)| rule).collect() -} - -/// Validate rule IDs in enable_rules and disable_rules lists. -fn validate_rule_ids( - enable_rules: &[String], - disable_rules: &[String], - registered_rules: &[String], -) -> Result<()> { - let mut invalid_rules = Vec::new(); - - // Check enable_rules - for rule in enable_rules { - if !registered_rules.contains(rule) { - invalid_rules.push(("enable", rule.clone())); - } - } - - // Check disable_rules - for rule in disable_rules { - if !registered_rules.contains(rule) { - invalid_rules.push(("disable", rule.clone())); - } - } - - if !invalid_rules.is_empty() { - let mut message = String::from("Invalid rule IDs provided:\n"); - for (filter_type, rule) in &invalid_rules { - message.push_str(&format!(" --{}-rule '{}': not found\n", filter_type, rule)); - let suggestions = suggest_rule_ids(rule, registered_rules, 2); - if !suggestions.is_empty() { - message.push_str(&format!(" Did you mean: {}?\n", suggestions.join(", "))); - } - } - message.push_str(&format!("\nAvailable rules: {}\n", registered_rules.join(", "))); - return Err(DebuggerError::InvalidArguments(message).into()); - } - - Ok(()) -} - -fn render_security_report(output: &AnalyzeCommandOutput) -> String { - let mut lines = Vec::new(); - - if let Some(dynamic) = &output.dynamic_analysis { - lines.push(format!("Dynamic analysis function: {}", dynamic.function)); - if let Some(args) = &dynamic.args { - lines.push(format!("Dynamic analysis args: {}", args)); - } - if let Some(result) = &dynamic.result { - lines.push(format!("Dynamic execution result: {}", result)); - } - lines.push(format!( - "Dynamic trace entries captured: {}", - dynamic.trace_entries - )); - lines.push(String::new()); - } - - if !output.warnings.is_empty() { - lines.push("Warnings:".to_string()); - for warning in &output.warnings { - lines.push(format!(" - {}", warning)); - } - lines.push(String::new()); - } - - if output.findings.is_empty() { - lines.push("No security findings detected.".to_string()); - if output.suppressed_count > 0 { - lines.push(format!( - "({} findings were suppressed)", - output.suppressed_count - )); - } - return lines.join("\n"); - } - - lines.push(format!( - "Findings: {} ({} suppressed)", - output.findings.len(), - output.suppressed_count - )); - for (idx, finding) in output.findings.iter().enumerate() { - lines.push(format!( - " {}. [{:?}] {} at {}", - idx + 1, - finding.severity, - finding.rule_id, - finding.location - )); - lines.push(format!(" {}", finding.description)); - if let Some(confidence) = finding.confidence { - lines.push(format!(" Confidence: {:.0}%", confidence * 100.0)); - } - if let Some(rationale) = &finding.rationale { - lines.push(format!(" Rationale: {}", rationale)); - } - lines.push(format!(" Remediation: {}", finding.remediation)); - } - - lines.join("\n") -} - -/// Run instruction-level stepping mode. -fn run_instruction_stepping( - engine: &mut DebuggerEngine, - function: &str, - args: Option<&str>, -) -> Result<()> { - logging::log_display( - "\n=== Instruction Stepping Mode ===", - logging::LogLevel::Info, - ); - logging::log_display( - "Type 'help' for available commands\n", - logging::LogLevel::Info, - ); - - display_instruction_context(engine, 3); - - loop { - print!("(step) > "); - std::io::Write::flush(&mut std::io::stdout()) - .map_err(|e| DebuggerError::IoError(format!("Failed to flush stdout: {}", e)))?; - - let mut input = String::new(); - let bytes_read = std::io::stdin() - .read_line(&mut input) - .map_err(|e| DebuggerError::IoError(format!("Failed to read line: {}", e)))?; - if bytes_read == 0 { - logging::log_display("Input stream closed.", logging::LogLevel::Info); - break; - } - - let input = input.trim().to_lowercase(); - let cmd = input.as_str(); - - let result = match cmd { - "n" | "next" | "s" | "step" | "into" | "" => engine.step_into(), - "o" | "over" => engine.step_over(), - "u" | "out" => engine.step_out(), - "b" | "block" => engine.step_block(), - "p" | "prev" | "back" => engine.step_back(), - "c" | "continue" => { - logging::log_display("Continuing execution...", logging::LogLevel::Info); - engine.continue_execution()?; - let res = engine.execute_without_breakpoints(function, args)?; - logging::log_display( - format!("Execution completed. Result: {:?}", res), - logging::LogLevel::Info, - ); - break; - } - "i" | "info" => { - display_instruction_info(engine); - continue; - } - "ctx" | "context" => { - display_instruction_context(engine, 5); - continue; - } - "h" | "help" => { - logging::log_display(Formatter::format_stepping_help(), logging::LogLevel::Info); - continue; - } - "q" | "quit" | "exit" => { - logging::log_display( - "Exiting instruction stepping mode...", - logging::LogLevel::Info, - ); - break; - } - _ => { - logging::log_display( - format!("Unknown command: {cmd}. Type 'help' for available commands."), - logging::LogLevel::Info, - ); - continue; - } - }; - - match result { - Ok(true) => display_instruction_context(engine, 3), - Ok(false) => { - let msg = if matches!(cmd, "p" | "prev" | "back") { - "Cannot step back: no previous instruction" - } else { - "Cannot step: execution finished or error occurred" - }; - logging::log_display(msg, logging::LogLevel::Info); - } - Err(e) => { - logging::log_display(format!("Error stepping: {}", e), logging::LogLevel::Info) - } - } - } - - Ok(()) -} - -fn display_instruction_context(engine: &DebuggerEngine, context_size: usize) { - let context = engine.get_instruction_context(context_size); - let formatted = Formatter::format_instruction_context(&context, context_size); - logging::log_display(formatted, logging::LogLevel::Info); -} - -fn display_instruction_info(engine: &DebuggerEngine) { - if let Ok(state) = engine.state().lock() { - let ip = state.instruction_pointer(); - let step_mode = if ip.is_stepping() { - Some(ip.step_mode()) - } else { - None - }; - - logging::log_display( - Formatter::format_instruction_pointer_state( - ip.current_index(), - ip.call_stack_depth(), - step_mode, - ip.is_stepping(), - ), - logging::LogLevel::Info, - ); - logging::log_display( - Formatter::format_instruction_stats( - state.instructions().len(), - ip.current_index(), - state.step_count(), - ), - logging::LogLevel::Info, - ); - - if let Some(inst) = state.current_instruction() { - logging::log_display( - format!( - "Current Instruction: {} (Offset: 0x{:08x}, Local index: {}, Control flow: {})", - inst.name(), - inst.offset, - inst.local_index, - inst.is_control_flow() - ), - logging::LogLevel::Info, - ); - } - } else { - logging::log_display("Cannot access debug state", logging::LogLevel::Info); - } -} - -/// Parse a step mode from its textual form. The single source of truth for -/// step-mode parsing across the run and interactive flows (#1263). Unsupported -/// modes return a clear error instead of silently defaulting, so a typo can't -/// quietly change stepping behaviour. -fn parse_step_mode(mode: &str) -> Result { - match mode.trim().to_lowercase().as_str() { - "into" | "i" => Ok(StepMode::StepInto), - "over" | "o" => Ok(StepMode::StepOver), - "out" | "u" => Ok(StepMode::StepOut), - "block" | "b" => Ok(StepMode::StepBlock), - other => Err(crate::DebuggerError::InvalidArguments(format!( - "unsupported step mode '{other}'. Supported modes: into, over, out, block." - )) - .into()), - } -} - -/// Recommended/required minimum length for a remote debug auth token (#1262). -const MIN_REMOTE_TOKEN_LEN: usize = 16; - -/// Outcome of the remote-debug token-strength policy (#1262). -#[derive(Debug, PartialEq, Eq)] -enum TokenPolicy { - Ok, - Warn(String), - Reject(String), -} - -/// Evaluate the token-strength policy for the remote debug server (#1262). -/// A token shorter than [`MIN_REMOTE_TOKEN_LEN`] warns by default, or is -/// rejected when `require_strong` is set. No token is allowed (auth disabled). -fn evaluate_token_policy(token: Option<&str>, require_strong: bool) -> TokenPolicy { - match token { - None => TokenPolicy::Ok, - Some(t) if t.trim().len() >= MIN_REMOTE_TOKEN_LEN => TokenPolicy::Ok, - Some(_) => { - let msg = format!( - "Remote debug token is shorter than {MIN_REMOTE_TOKEN_LEN} characters. \ - Prefer at least {MIN_REMOTE_TOKEN_LEN} characters, ideally a random 32-byte token." - ); - if require_strong { - TokenPolicy::Reject(format!( - "{msg} Refusing to start because --require-strong-token is set." - )) - } else { - TokenPolicy::Warn(msg) - } - } - } -} - -#[cfg(test)] -mod step_and_token_tests { - use super::*; - use crate::debugger::instruction_pointer::StepMode; - - #[test] - fn parse_step_mode_accepts_supported_modes_and_aliases() { - assert_eq!(parse_step_mode("into").unwrap(), StepMode::StepInto); - assert_eq!(parse_step_mode("OVER").unwrap(), StepMode::StepOver); - assert_eq!(parse_step_mode(" out ").unwrap(), StepMode::StepOut); - assert_eq!(parse_step_mode("b").unwrap(), StepMode::StepBlock); - } - - #[test] - fn parse_step_mode_rejects_unsupported_mode() { - let err = parse_step_mode("sideways").unwrap_err().to_string(); - assert!(err.contains("unsupported step mode"), "got: {err}"); - assert!(err.contains("sideways"), "got: {err}"); - } - - #[test] - fn token_policy_ok_when_absent_or_long_enough() { - assert_eq!(evaluate_token_policy(None, true), TokenPolicy::Ok); - assert_eq!( - evaluate_token_policy(Some("0123456789abcdef"), true), - TokenPolicy::Ok - ); - } - - #[test] - fn token_policy_warns_by_default_for_short_token() { - assert!(matches!( - evaluate_token_policy(Some("short"), false), - TokenPolicy::Warn(_) - )); - } - - #[test] - fn token_policy_rejects_short_token_when_enforcement_enabled() { - assert!(matches!( - evaluate_token_policy(Some("short"), true), - TokenPolicy::Reject(_) - )); - } -} - -/// Display mock call log -fn display_mock_call_log(calls: &[crate::runtime::executor::MockCallEntry]) { - if calls.is_empty() { - return; - } - print_info("\n--- Mock Contract Calls ---"); - for (i, entry) in calls.iter().enumerate() { - let status = if entry.mocked { "MOCKED" } else { "REAL" }; - print_info(format!( - "{}. {} {} (args: {}) -> {}", - i + 1, - status, - entry.function, - entry.args_count, - if entry.returned.is_some() { - "returned" - } else { - "pending" - } - )); - } -} - -/// Execute batch mode with parallel execution -fn run_batch(args: &RunArgs, batch_file: &std::path::Path) -> Result<()> { - let contract = args - .contract - .as_ref() - .expect("contract is required for batch mode"); - let function = args - .function - .as_ref() - .expect("function is required for batch mode"); - - print_info(format!("Loading contract: {:?}", contract)); - logging::log_loading_contract(&contract.to_string_lossy()); - - let wasm_bytes = fs::read(contract).map_err(|e| { - DebuggerError::WasmLoadError(format!("Failed to read WASM file at {:?}: {}", contract, e)) - })?; - - print_success(format!( - "Contract loaded successfully ({} bytes)", - wasm_bytes.len() - )); - logging::log_contract_loaded(wasm_bytes.len()); - - print_info(format!("Loading batch file: {:?}", batch_file)); - let batch_items = crate::batch::BatchExecutor::load_batch_file(batch_file)?; - print_success(format!("Loaded {} test cases", batch_items.len())); - - if let Some(snapshot_path) = &args.network_snapshot { - print_info(format!("\nLoading network snapshot: {:?}", snapshot_path)); - logging::log_loading_snapshot(&snapshot_path.to_string_lossy()); - let loader = SnapshotLoader::from_file(snapshot_path)?; - let loaded_snapshot = loader.apply_to_environment()?; - logging::log_display(loaded_snapshot.format_summary(), logging::LogLevel::Info); - } - - print_info(format!( - "\nExecuting {} test cases in parallel for function: {}", - batch_items.len(), - function - )); - logging::log_execution_start(function, None); - - let executor = crate::batch::BatchExecutor::new(wasm_bytes, function.clone())?; - let results = executor.execute_batch(batch_items)?; - let summary = crate::batch::BatchExecutor::summarize(&results); - - crate::batch::BatchExecutor::display_results(&results, &summary); - - if args.is_json_output() { - let output = serde_json::json!({ - "results": results, - "summary": summary, - }); - logging::log_display( - crate::output::to_json_string(&output).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize output: {}", e)) - })?, - logging::LogLevel::Info, - ); - } - - logging::log_execution_complete(&format!("{}/{} passed", summary.passed, summary.total)); - - if summary.failed > 0 || summary.errors > 0 { - return Err(DebuggerError::ExecutionError(format!( - "Batch execution completed with failures: {} failed, {} errors", - summary.failed, summary.errors - )) - .into()); - } - - Ok(()) -} - -/// Execute the run command. -#[tracing::instrument(skip_all, fields(contract = ?args.contract, function = args.function))] -pub fn run(args: RunArgs, verbosity: Verbosity) -> Result<()> { - // Start debug server if requested - if args.server { - return server(ServerArgs { - host: args.host, - port: args.port, - token: args.token, - require_strong_token: false, - tls_cert: args.tls_cert, - tls_key: args.tls_key, - repeat: args.repeat, - storage_filter: args.storage_filter, - show_events: args.show_events, - event_filter: args.event_filter, - mock: args.mock, - }); - } - - // Remote execution/ping path. - if let Some(remote_addr) = &args.remote { - return remote( - RemoteArgs { - remote: remote_addr.clone(), - token: args.token.clone(), - contract: args.contract.clone(), - function: args.function.clone(), - tls_cert: args.tls_cert.clone(), - tls_key: args.tls_key.clone(), - tls_ca: None, - session_label: None, - args: args.args.clone(), - connect_timeout_ms: 10000, - timeout_ms: 30000, - inspect_timeout_ms: None, - storage_timeout_ms: None, - retry_attempts: 3, - retry_base_delay_ms: 200, - retry_max_delay_ms: 2000, - format: if args.is_json_output() { crate::cli::args::OutputFormat::Json } else { crate::cli::args::OutputFormat::Pretty }, - action: None, - }, - verbosity, - ); - } - - // Initialize output writer - let mut output_writer = OutputWriter::new(args.save_output.as_deref(), args.append)?; - - // Handle batch execution mode - if let Some(batch_file) = &args.batch_args { - return run_batch(&args, batch_file); - } - - if args.dry_run { - return run_dry_run(&args); - } - - let contract = args - .contract - .as_ref() - .expect("contract is required for run"); - let function = args - .function - .as_ref() - .expect("function is required for run"); - - print_info(format!("Loading contract: {:?}", contract)); - output_writer.write(&format!("Loading contract: {:?}", contract))?; - logging::log_loading_contract(&contract.to_string_lossy()); - - let wasm_file = crate::utils::wasm::load_wasm(contract) - .with_context(|| format!("Failed to read WASM file: {:?}", contract))?; - let wasm_bytes = wasm_file.bytes; - let wasm_hash = wasm_file.sha256_hash; - - if let Some(expected) = &args.expected_hash { - if expected.to_lowercase() != wasm_hash { - return Err((crate::DebuggerError::ChecksumMismatch( - expected.clone(), - wasm_hash.clone(), - )) - .into()); - } - } - - print_success(format!( - "Contract loaded successfully ({} bytes)", - wasm_bytes.len() - )); - output_writer.write(&format!( - "Contract loaded successfully ({} bytes)", - wasm_bytes.len() - ))?; - - if args.verbose || verbosity == Verbosity::Verbose { - print_verbose(format!("SHA-256: {}", wasm_hash)); - output_writer.write(&format!("SHA-256: {}", wasm_hash))?; - if args.expected_hash.is_some() { - print_verbose("Checksum verified βœ“"); - output_writer.write("Checksum verified βœ“")?; - } - } - - logging::log_contract_loaded(wasm_bytes.len()); - - if let Some(snapshot_path) = &args.network_snapshot { - print_info(format!("\nLoading network snapshot: {:?}", snapshot_path)); - output_writer.write(&format!("Loading network snapshot: {:?}", snapshot_path))?; - logging::log_loading_snapshot(&snapshot_path.to_string_lossy()); - let loader = SnapshotLoader::from_file(snapshot_path)?; - let loaded_snapshot = loader.apply_to_environment()?; - output_writer.write(&loaded_snapshot.format_summary())?; - logging::log_display(loaded_snapshot.format_summary(), logging::LogLevel::Info); - } - - let parsed_args = if let Some(args_json) = &args.args { - Some(parse_args(args_json)?) - } else { - None - }; - - let mut initial_storage = if let Some(storage_json) = &args.storage { - Some(parse_storage(storage_json)?) - } else { - None - }; - - // Import storage if specified - if let Some(import_path) = &args.import_storage { - print_info(format!("Importing storage from: {:?}", import_path)); - let imported = crate::inspector::storage::StorageState::import_from_file(import_path)?; - print_success(format!("Imported {} storage entries", imported.len())); - initial_storage = Some(serde_json::to_string(&imported).map_err(|e| { - DebuggerError::StorageError(format!("Failed to serialize imported storage: {}", e)) - })?); - } - - if let Some(n) = args.repeat { - logging::log_repeat_execution(function, n as usize); - let runner = RepeatRunner::new(wasm_bytes, args.breakpoint, initial_storage); - let stats = runner.run(function, parsed_args.as_deref(), n)?; - stats.display(); - return Ok(()); - } - - print_info("\nStarting debugger..."); - output_writer.write("Starting debugger...")?; - print_info(format!("Function: {}", function)); - output_writer.write(&format!("Function: {}", function))?; - if let Some(ref parsed) = parsed_args { - print_info(format!("Arguments: {}", parsed)); - output_writer.write(&format!("Arguments: {}", parsed))?; - } - logging::log_execution_start(function, parsed_args.as_deref()); - - let mut executor = ContractExecutor::new(wasm_bytes.clone())?; - executor.set_timeout(args.timeout); - - if let Some(storage) = initial_storage { - executor.set_initial_storage(storage)?; - } - if !args.mock.is_empty() { - executor.set_mock_specs(&args.mock)?; - } - - let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone()); - - if args.instruction_debug { - print_info("Enabling instruction-level debugging..."); - engine.enable_instruction_debug(&wasm_bytes)?; - - if args.step_instructions { - let step_mode = parse_step_mode(&args.step_mode)?; - print_info(format!( - "Starting instruction stepping in '{}' mode", - args.step_mode - )); - engine.start_instruction_stepping(step_mode)?; - run_instruction_stepping(&mut engine, function, parsed_args.as_deref())?; - return Ok(()); - } - } - - print_info("\n--- Execution Start ---\n"); - output_writer.write("\n--- Execution Start ---\n")?; - let storage_before = engine.executor().get_storage_snapshot()?; - let result = engine.execute(function, parsed_args.as_deref())?; - let storage_after = engine.executor().get_storage_snapshot()?; - print_success("\n--- Execution Complete ---\n"); - output_writer.write("\n--- Execution Complete ---\n")?; - print_result(format!("Result: {:?}", result)); - output_writer.write(&format!("Result: {:?}", result))?; - logging::log_execution_complete(&result); - - // Generate test if requested - if let Some(test_path) = &args.generate_test { - if let Some(record) = engine.executor().last_execution() { - print_info(format!("\nGenerating unit test: {:?}", test_path)); - let test_code = crate::codegen::TestGenerator::generate(record, contract)?; - crate::codegen::TestGenerator::write_to_file(test_path, &test_code, args.overwrite)?; - print_success(format!( - "Unit test generated successfully at {:?}", - test_path - )); - } else { - print_warning("No execution record found to generate test."); - } - } - - let storage_diff = crate::inspector::storage::StorageInspector::compute_diff( - &storage_before, - &storage_after, - &args.alert_on_change, - ); - if !storage_diff.is_empty() || !args.alert_on_change.is_empty() { - print_info("\n--- Storage Changes ---"); - crate::inspector::storage::StorageInspector::display_diff(&storage_diff); - } - - let mock_calls = engine.executor().get_mock_call_log(); - if !args.mock.is_empty() { - display_mock_call_log(&mock_calls); - } - - // Save budget info to history - let host = engine.executor().host(); - let budget = crate::inspector::budget::BudgetInspector::get_cpu_usage(host); - if let Ok(manager) = HistoryManager::new() { - let record = RunHistory { - date: chrono::Utc::now().to_rfc3339(), - contract_hash: contract.to_string_lossy().to_string(), - function: function.clone(), - cpu_used: budget.cpu_instructions, - memory_used: budget.memory_bytes, - }; - let _ = manager.append_record(record); - } - let _json_memory_summary = engine.executor().last_memory_summary().cloned(); - - // Export storage if specified - if let Some(export_path) = &args.export_storage { - print_info(format!("Exporting storage to: {:?}", export_path)); - let storage_snapshot = engine.executor().get_storage_snapshot()?; - crate::inspector::storage::StorageState::export_to_file(&storage_snapshot, export_path)?; - print_success(format!( - "Exported {} storage entries", - storage_snapshot.len() - )); - } - - let mut json_events = None; - if args.show_events || !args.event_filter.is_empty() || args.filter_topic.is_some() { - print_info("\n--- Events ---"); - - // Attempt to read raw events from executor - let raw_events = engine.executor().get_events()?; - - // Convert runtime event objects into our inspector::events::ContractEvent via serde translation. - // This is a generic, safe conversion as long as runtime events are serializable with sensible fields. - let converted_events: Vec = - match serde_json::to_value(&raw_events).and_then(serde_json::from_value) { - Ok(evts) => evts, - Err(e) => { - // If conversion fails, fall back to attempting to stringify each raw event for display. - print_warning(format!( - "Failed to convert runtime events for structured display: {}", - e - )); - // Fallback: attempt a best-effort stringification - let fallback: Vec = raw_events - .into_iter() - .map(|r| ContractEvent { - contract_id: None, - topics: vec![], - data: format!("{:?}", r), - }) - .collect(); - fallback - } - }; - - // Determine filter: prefer repeatable --event-filter, fallback to legacy --filter-topic - let filter_opt = if !args.event_filter.is_empty() { - Some(args.event_filter.join(",")) - } else { - args.filter_topic.clone() - }; - - let filtered_events = if let Some(ref filt) = filter_opt { - EventInspector::filter_events(&converted_events, filt) - } else { - converted_events.clone() - }; - - if filtered_events.is_empty() { - print_warning("No events captured."); - } else { - // Display events in readable form - let lines = EventInspector::format_events(&filtered_events); - for line in &lines { - print_info(line); - } - } - - json_events = Some(filtered_events); - } - - if !args.storage_filter.is_empty() { - let storage_filter = crate::inspector::storage::StorageFilter::new(&args.storage_filter) - .map_err(|e| DebuggerError::StorageError(format!("Invalid storage filter: {}", e)))?; - - print_info("\n--- Storage ---"); - let inspector = - crate::inspector::storage::StorageInspector::with_state(storage_after.clone()); - inspector.display_filtered(&storage_filter); - } - - let mut json_auth = None; - if args.show_auth { - let auth_tree = engine.executor().get_auth_tree()?; - if args.json { - // JSON mode: print the auth tree inline (will also be included in - // the combined JSON object further below). - let json_output = crate::inspector::auth::AuthInspector::to_json(&auth_tree)?; - logging::log_display(json_output, logging::LogLevel::Info); - } else { - print_info("\n--- Authorization Tree ---"); - crate::inspector::auth::AuthInspector::display_with_summary(&auth_tree); - } - json_auth = Some(auth_tree); - } - - let mut json_ledger = None; - if args.show_ledger { - print_info("\n--- Ledger Entries ---"); - let mut ledger_inspector = crate::inspector::ledger::LedgerEntryInspector::new(); - ledger_inspector.set_ttl_warning_threshold(args.ttl_warning_threshold); - - match engine.executor_mut().finish() { - Ok((footprint, storage)) => { - #[allow(clippy::clone_on_copy)] - let mut footprint_map = std::collections::HashMap::new(); - for (k, v) in &footprint.0 { - #[allow(clippy::clone_on_copy)] - footprint_map.insert(k.clone(), v.clone()); - footprint_map.insert(k.clone(), *v); - } - - for (key, val_opt) in &storage.map { - if let Some(access_type) = footprint_map.get(key) { - if let Some((entry, ttl)) = val_opt { - let key_str = format!("{:?}", **key); - let storage_type = - if key_str.contains("Temporary") || key_str.contains("temporary") { - crate::inspector::ledger::StorageType::Temporary - } else if key_str.contains("Instance") - || key_str.contains("instance") - || key_str.contains("LedgerKeyContractInstance") - { - crate::inspector::ledger::StorageType::Instance - } else { - crate::inspector::ledger::StorageType::Persistent - }; - - use soroban_env_host::storage::AccessType; - let is_read = true; // Everything in the footprint is at least read - let is_write = matches!(*access_type, AccessType::ReadWrite); - - ledger_inspector.add_entry( - format!("{:?}", **key), - format!("{:?}", **entry), - storage_type, - ttl.unwrap_or(0), - is_read, - is_write, - ); - } - } - } - } - Err(e) => { - print_warning(format!("Failed to extract ledger footprint: {}", e)); - } - } - - ledger_inspector.display(); - ledger_inspector.display_warnings(); - json_ledger = Some(ledger_inspector); - } - - if args.is_json_output() { - let mut result_obj = serde_json::json!({ - "result": result, - "sha256": wasm_hash, - "budget": { - "cpu_instructions": budget.cpu_instructions, - "memory_bytes": budget.memory_bytes, - }, - "storage_diff": storage_diff, - }); - - if let Some(ref events) = json_events { - result_obj["events"] = EventInspector::to_json_value(events); - } - if let Some(auth_tree) = json_auth { - result_obj["auth"] = crate::inspector::auth::AuthInspector::to_json_value(&auth_tree); - } - if !mock_calls.is_empty() { - result_obj["mock_calls"] = serde_json::Value::Array( - mock_calls - .iter() - .map(|entry| { - serde_json::json!({ - "contract_id": entry.contract_id, - "function": entry.function, - "args_count": entry.args_count, - "mocked": entry.mocked, - "returned": entry.returned, - }) - }) - .collect(), - ); - } - if let Some(ref ledger) = json_ledger { - result_obj["ledger_entries"] = ledger.to_json(); - } - - let output = crate::output::VersionedOutput::success("run", result_obj); - - match crate::output::to_json_string(&output) { - Ok(json) => println!("{}", json), - Err(e) => { - let err_output = crate::output::VersionedOutput::::error( - "run", - format!("Failed to serialize output: {}", e), - ); - if let Ok(err_json) = crate::output::to_json_string(&err_output) { - println!("{}", err_json); - } - } - } - } - - if let Some(trace_path) = &args.trace_output { - print_info(format!("\nExporting execution trace to: {:?}", trace_path)); - - let args_str = parsed_args - .as_ref() - .map(|a| serde_json::to_string(a).unwrap_or_default()); - - let trace_events = json_events - .clone() - .unwrap_or_else(|| engine.executor().get_events().unwrap_or_default()); - - let trace = build_execution_trace( - function, - contract.to_string_lossy().as_ref(), - args_str, - &storage_after, - &result, - budget.clone(), - engine.executor(), - &trace_events, - usize::MAX, - ); - - if let Ok(json) = trace.to_json() { - if let Err(e) = std::fs::write(trace_path, json) { - print_warning(format!("Failed to write trace to {:?}: {}", trace_path, e)); - } else { - print_success(format!("Successfully exported trace to {:?}", trace_path)); - if let Err(e) = - export_replay_artifact_manifest(&trace, trace_path, contract.as_ref(), &args) - { - print_warning(format!( - "Failed to write replay artifact manifest for {:?}: {}", - trace_path, e - )); - } - } - } - } - - if let Some(timeline_path) = &args.timeline_output { - print_info(format!( - "\nExporting timeline narrative to: {:?}", - timeline_path - )); - - let stack_summary = engine - .state() - .lock() - .ok() - .map(|state| state.call_stack().get_stack().to_vec()) - .unwrap_or_default(); - - let mut warnings = Vec::new(); - if !storage_diff.triggered_alerts.is_empty() { - warnings.push(TimelineWarning { - kind: "storage_alert".to_string(), - message: format!( - "Triggered storage alert(s): {}", - storage_diff.triggered_alerts.join(", ") - ), - }); - } - - let events_count = json_events - .as_ref() - .map(|ev| ev.len()) - .or_else(|| engine.executor().get_events().ok().map(|ev| ev.len())); - - let storage_delta = if storage_diff.is_empty() { - None - } else { - Some(TimelineStorageDelta::from_storage_diff(&storage_diff, 200)) - }; - - let mut pauses = Vec::new(); - // Record the actual classified pause reason (breakpoint / step_boundary / - // panic / end_of_execution / user_interrupt) from engine state rather than - // hardcoding "breakpoint" (#1264), so the exported timeline explains why - // execution paused. - let classified_reason = engine - .state() - .lock() - .ok() - .and_then(|s| s.pause_reason()) - .map(|r| r.as_str().to_string()); - if let Some(reason) = classified_reason { - pauses.push(TimelinePausePoint { - index: 0, - reason, - location: None, - call_stack: stack_summary.clone(), - }); - } else if engine.is_paused() && args.breakpoint.iter().any(|bp| bp == function) { - // Paused at the entry breakpoint without a classified reason (prior behavior). - pauses.push(TimelinePausePoint { - index: 0, - reason: "breakpoint".to_string(), - location: None, - call_stack: stack_summary.clone(), - storage_mutation, - }); - } - - let export = TimelineExport { - schema_version: TIMELINE_EXPORT_SCHEMA_VERSION, - created_at: chrono::Utc::now().to_rfc3339(), - run: TimelineRunInfo { - contract_path: contract.to_string_lossy().to_string(), - wasm_sha256: Some(wasm_hash.clone()), - function: function.to_string(), - args_json: args.args.clone(), - result: Some(result.clone()), - error: None, - budget: Some(budget.clone()), - events_count, - }, - pauses, - stack_summary, - deltas: TimelineDeltas { - storage: storage_delta, - }, - warnings, - }; - - if let Err(e) = write_json_pretty_file(timeline_path, &export) { - print_warning(format!( - "Failed to write timeline narrative to {:?}: {}", - timeline_path, e - )); - } else { - print_success(format!( - "Successfully exported timeline narrative to {:?}", - timeline_path - )); - } - } - - Ok(()) -} - -#[allow(clippy::too_many_arguments)] -fn build_execution_trace( - function: &str, - contract_path: &str, - args_str: Option, - storage_after: &std::collections::HashMap, - result: &str, - budget: crate::inspector::budget::BudgetInfo, - executor: &ContractExecutor, - events: &[crate::inspector::events::ContractEvent], - replay_until: usize, -) -> crate::compare::ExecutionTrace { - let mut trace_storage = std::collections::BTreeMap::new(); - for (k, v) in storage_after { - if let Ok(val) = serde_json::from_str(v) { - trace_storage.insert(k.clone(), val); - } else { - trace_storage.insert(k.clone(), serde_json::Value::String(v.clone())); - } - } - - let return_val = serde_json::from_str(result) - .unwrap_or_else(|_| serde_json::Value::String(result.to_string())); - - let mut call_sequence = Vec::new(); - let mut depth = 0; - - call_sequence.push(crate::compare::trace::CallEntry { - function: function.to_string(), - args: args_str.clone(), - depth, - }); - - if let Ok(diag_events) = executor.get_diagnostic_events() { - for event in diag_events { - // Stop building trace if we hit the replay limit - if call_sequence.len() >= replay_until { - break; - } - - let event_str = format!("{:?}", event); - if event_str.contains("ContractCall") - || (event_str.contains("call") && event.contract_id.is_some()) - { - depth += 1; - call_sequence.push(crate::compare::trace::CallEntry { - function: "nested_call".to_string(), - args: None, - depth, - }); - } else if (event_str.contains("ContractReturn") || event_str.contains("return")) - && depth > 0 - { - depth -= 1; - } - } - } - - let mut trace_events = Vec::new(); - for e in events { - trace_events.push(crate::compare::trace::EventEntry { - contract_id: e.contract_id.clone(), - topics: e.topics.clone(), - data: Some(e.data.clone()), - }); - } - - crate::compare::ExecutionTrace { - label: Some(format!("Execution of {} on {}", function, contract_path)), - contract: Some(contract_path.to_string()), - function: Some(function.to_string()), - args: args_str, - storage: trace_storage, - budget: Some(crate::compare::trace::BudgetTrace { - cpu_instructions: budget.cpu_instructions, - memory_bytes: budget.memory_bytes, - cpu_limit: None, - memory_limit: None, - }), - return_value: Some(return_val), - call_sequence, - events: trace_events, - } -} - -fn export_replay_artifact_manifest( - trace: &crate::compare::ExecutionTrace, - trace_path: &std::path::Path, - contract_path: &std::path::Path, - args: &RunArgs, -) -> Result<()> { - let manifest_path = crate::compare::ExecutionTrace::manifest_path_for_trace(trace_path); - let mut manifest = trace.to_replay_artifact_manifest(trace_path); - - manifest.files.push(crate::output::ReplayArtifactFile { - kind: crate::output::ReplayArtifactKind::Manifest, - path: manifest_path.display().to_string(), - description: Some("Replay artifact manifest".to_string()), - compression: None, - }); - manifest.files.push(crate::output::ReplayArtifactFile { - kind: crate::output::ReplayArtifactKind::ContractWasm, - path: contract_path.display().to_string(), - description: Some("Contract WASM used to generate the trace".to_string()), - compression: None, - }); - - if let Some(path) = &args.network_snapshot { - manifest.files.push(crate::output::ReplayArtifactFile { - kind: crate::output::ReplayArtifactKind::NetworkSnapshot, - path: path.display().to_string(), - description: Some("Network snapshot loaded before execution".to_string()), - compression: None, - }); - } - if let Some(path) = &args.import_storage { - manifest.files.push(crate::output::ReplayArtifactFile { - kind: crate::output::ReplayArtifactKind::StorageImport, - path: path.display().to_string(), - description: Some("Imported storage seed used before execution".to_string()), - compression: None, - }); - } - if let Some(path) = &args.export_storage { - manifest.files.push(crate::output::ReplayArtifactFile { - kind: crate::output::ReplayArtifactKind::StorageExport, - path: path.display().to_string(), - description: Some("Exported storage state captured after execution".to_string()), - compression: None, - }); - } - if let Some(path) = &args.save_output { - manifest.files.push(crate::output::ReplayArtifactFile { - kind: crate::output::ReplayArtifactKind::OutputReport, - path: path.display().to_string(), - description: Some("Saved command output for this run".to_string()), - compression: None, - }); - } - if let Some(path) = &args.generate_test { - manifest.files.push(crate::output::ReplayArtifactFile { - kind: crate::output::ReplayArtifactKind::GeneratedTest, - path: path.display().to_string(), - description: Some("Generated reproduction test derived from the trace".to_string()), - compression: None, - }); - } - - crate::history::write_json_atomically(&manifest_path, &manifest)?; - print_success(format!( - "Replay artifact manifest written to {:?}", - manifest_path - )); - Ok(()) -} - -/// Execute run command in dry-run mode. -fn run_dry_run(args: &RunArgs) -> Result<()> { - let contract = args - .contract - .as_ref() - .expect("contract is required for dry-run"); - print_info(format!("[DRY RUN] Loading contract: {:?}", contract)); - - let wasm_file = crate::utils::wasm::load_wasm(contract) - .with_context(|| format!("Failed to read WASM file: {:?}", contract))?; - let wasm_bytes = wasm_file.bytes; - let wasm_hash = wasm_file.sha256_hash; - - if let Some(expected) = &args.expected_hash { - if expected.to_lowercase() != wasm_hash { - return Err((crate::DebuggerError::ChecksumMismatch( - expected.clone(), - wasm_hash.clone(), - )) - .into()); - } - } - - print_success(format!( - "[DRY RUN] Contract loaded successfully ({} bytes)", - wasm_bytes.len() - )); - - if args.verbose { - print_verbose(format!("[DRY RUN] SHA-256: {}", wasm_hash)); - if args.expected_hash.is_some() { - print_verbose("[DRY RUN] Checksum verified βœ“"); - } - } - - print_info("[DRY RUN] Skipping execution"); - - Ok(()) -} - -/// Get instruction counts from the debugger engine -#[allow(dead_code)] -fn get_instruction_counts( - engine: &DebuggerEngine, -) -> Option { - // Try to get instruction counts from the executor - engine.executor().get_instruction_counts().ok() -} - -/// Display instruction counts per function in a formatted table -#[allow(dead_code)] -fn display_instruction_counts(counts: &crate::runtime::executor::InstructionCounts) { - if counts.function_counts.is_empty() { - return; - } - - print_info("\n--- Instruction Count per Function ---"); - - // Calculate percentages - let percentages: Vec = counts - .function_counts - .iter() - .map(|(_, count)| { - if counts.total > 0 { - ((*count as f64) / (counts.total as f64)) * 100.0 - } else { - 0.0 - } - }) - .collect(); - - // Find max widths for alignment - let max_func_width = counts - .function_counts - .iter() - .map(|(name, _)| name.len()) - .max() - .unwrap_or(20); - let max_count_width = counts - .function_counts - .iter() - .map(|(_, count)| count.to_string().len()) - .max() - .unwrap_or(10); - - // Print header - let header = format!( - "{:width2$} | {:>width3$}", - "Function", - "Instructions", - "Percentage", - width1 = max_func_width, - width2 = max_count_width, - width3 = 10 - ); - print_info(&header); - print_info("-".repeat(header.len())); - - // Print rows - for ((func_name, count), percentage) in counts.function_counts.iter().zip(percentages.iter()) { - let row = format!( - "{:width2$} | {:>7.2}%", - func_name, - count, - percentage, - width1 = max_func_width, - width2 = max_count_width - ); - print_info(&row); - } -} - -/// Execute the upgrade-check command -pub fn upgrade_check(args: UpgradeCheckArgs) -> Result<()> { - print_info(format!("Loading old contract: {:?}", args.old)); - let old_wasm = fs::read(&args.old) - .map_err(|e| miette::miette!("Failed to read old WASM file {:?}: {}", args.old, e))?; - - print_info(format!("Loading new contract: {:?}", args.new)); - let new_wasm = fs::read(&args.new) - .map_err(|e| miette::miette!("Failed to read new WASM file {:?}: {}", args.new, e))?; - - // Optionally run test inputs against both versions - let execution_diffs = if let Some(inputs_json) = &args.test_inputs { - run_test_inputs(inputs_json, &old_wasm, &new_wasm)? - } else { - Vec::new() - }; - - let old_path = args.old.to_string_lossy().to_string(); - let new_path = args.new.to_string_lossy().to_string(); - - let report = - UpgradeAnalyzer::analyze(&old_wasm, &new_wasm, &old_path, &new_path, execution_diffs)?; - - let output = match args.output.as_str() { - "json" => { - let envelope = crate::output::VersionedOutput::success("upgrade-check", &report); - crate::output::to_json_string(&envelope) - .map_err(|e| miette::miette!("Failed to serialize report: {}", e))? - } - _ => format_text_report(&report), - }; - - if let Some(out_file) = &args.output_file { - fs::write(out_file, &output) - .map_err(|e| miette::miette!("Failed to write report to {:?}: {}", out_file, e))?; - print_success(format!("Report written to {:?}", out_file)); - } else { - println!("{}", output); - } - - if !report.is_compatible { - return Err(miette::miette!( - "Contracts are not compatible: {} breaking change(s) detected", - report.breaking_changes.len() - )); - } - - Ok(()) -} - -/// Run test inputs against both WASM versions and collect diffs -fn run_test_inputs( - inputs_json: &str, - old_wasm: &[u8], - new_wasm: &[u8], -) -> Result> { - let inputs: serde_json::Map = serde_json - ::from_str(inputs_json) - .map_err(|e| - miette::miette!( - "Invalid --test-inputs JSON (expected an object mapping function names to arg arrays): {}", - e - ) - )?; - - let mut diffs = Vec::new(); - - for (func_name, args_val) in &inputs { - let args_str = args_val.to_string(); - - let old_result = invoke_wasm(old_wasm, func_name, &args_str); - let new_result = invoke_wasm(new_wasm, func_name, &args_str); - - let outputs_match = old_result == new_result; - diffs.push(ExecutionDiff { - function: func_name.clone(), - args: args_str, - old_result, - new_result, - outputs_match, - }); - } - - Ok(diffs) -} - -/// Invoke a function on a WASM contract and return a string representation of the result -fn invoke_wasm(wasm: &[u8], function: &str, args: &str) -> String { - match ContractExecutor::new(wasm.to_vec()) { - Err(e) => format!("Err(executor: {})", e), - Ok(executor) => { - let mut engine = DebuggerEngine::new(executor, vec![]); - let parsed = if args == "null" || args == "[]" { - None - } else { - Some(args.to_string()) - }; - match engine.execute(function, parsed.as_deref()) { - Ok(val) => format!("Ok({:?})", val), - Err(e) => format!("Err({})", e), - } - } - } -} - -/// Format a compatibility report as human-readable text -fn format_text_report(report: &CompatibilityReport) -> String { - let mut out = String::new(); - - out.push_str("Contract Upgrade Compatibility Report\n"); - out.push_str("======================================\n"); - out.push_str(&format!("Old: {}\n", report.old_wasm_path)); - out.push_str(&format!("New: {}\n", report.new_wasm_path)); - out.push('\n'); - - let status = if report.is_compatible { - "COMPATIBLE" - } else { - "INCOMPATIBLE" - }; - out.push_str(&format!( - "Status: {} (Classification: {})\n", - status, report.classification - )); - - out.push('\n'); - out.push_str(&format!( - "Breaking Changes ({}):\n", - report.breaking_changes.len() - )); - if report.breaking_changes.is_empty() { - out.push_str(" (none)\n"); - } else { - for change in &report.breaking_changes { - out.push_str(&format!(" {}\n", change)); - } - } - - out.push('\n'); - out.push_str(&format!( - "Non-Breaking Changes ({}):\n", - report.non_breaking_changes.len() - )); - if report.non_breaking_changes.is_empty() { - out.push_str(" (none)\n"); - } else { - for change in &report.non_breaking_changes { - out.push_str(&format!(" {}\n", change)); - } - } - - if !report.execution_diffs.is_empty() { - out.push('\n'); - out.push_str(&format!( - "Execution Diffs ({}):\n", - report.execution_diffs.len() - )); - for diff in &report.execution_diffs { - let match_str = if diff.outputs_match { - "MATCH" - } else { - "MISMATCH" - }; - out.push_str(&format!( - " {} args={} OLD={} NEW={} [{}]\n", - diff.function, diff.args, diff.old_result, diff.new_result, match_str - )); - } - } - - out.push('\n'); - let old_names: Vec<&str> = report - .old_functions - .iter() - .map(|f| f.name.as_str()) - .collect(); - let new_names: Vec<&str> = report - .new_functions - .iter() - .map(|f| f.name.as_str()) - .collect(); - out.push_str(&format!( - "Old Functions ({}): {}\n", - old_names.len(), - old_names.join(", ") - )); - out.push_str(&format!( - "New Functions ({}): {}\n", - new_names.len(), - new_names.join(", ") - )); - - out -} - -/// Parse JSON arguments with validation. -pub fn parse_args(json: &str) -> Result { - let value = serde_json::from_str::(json).map_err(|e| { - DebuggerError::InvalidArguments(format!( - "Failed to parse JSON arguments: {}. Error: {}", - json, e - )) - })?; - - match value { - serde_json::Value::Array(ref arr) => { - tracing::debug!(count = arr.len(), "Parsed array arguments"); - } - serde_json::Value::Object(ref obj) => { - tracing::debug!(fields = obj.len(), "Parsed object arguments"); - } - _ => { - tracing::debug!("Parsed single value argument"); - } - } - - Ok(json.to_string()) -} - -/// Parse JSON storage. -pub fn parse_storage(json: &str) -> Result { - serde_json::from_str::(json).map_err(|e| { - DebuggerError::StorageError(format!( - "Failed to parse JSON storage: {}. Error: {}", - json, e - )) - })?; - Ok(json.to_string()) -} - -/// Execute the optimize command. -pub fn optimize(args: OptimizeArgs, _verbosity: Verbosity) -> Result<()> { - print_info(format!( - "Analyzing contract for gas optimization: {:?}", - args.contract - )); - logging::log_loading_contract(&args.contract.to_string_lossy()); - - let wasm_file = crate::utils::wasm::load_wasm(&args.contract) - .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; - let wasm_bytes = wasm_file.bytes; - let wasm_hash = wasm_file.sha256_hash; - - if let Some(expected) = &args.expected_hash { - if expected.to_lowercase() != wasm_hash { - return Err((crate::DebuggerError::ChecksumMismatch( - expected.clone(), - wasm_hash.clone(), - )) - .into()); - } - } - - print_success(format!( - "Contract loaded successfully ({} bytes)", - wasm_bytes.len() - )); - - if _verbosity == Verbosity::Verbose { - print_verbose(format!("SHA-256: {}", wasm_hash)); - if args.expected_hash.is_some() { - print_verbose("Checksum verified βœ“"); - } - } - - logging::log_contract_loaded(wasm_bytes.len()); - - if let Some(snapshot_path) = &args.network_snapshot { - print_info(format!("\nLoading network snapshot: {:?}", snapshot_path)); - logging::log_loading_snapshot(&snapshot_path.to_string_lossy()); - let loader = SnapshotLoader::from_file(snapshot_path)?; - let loaded_snapshot = loader.apply_to_environment()?; - logging::log_display(loaded_snapshot.format_summary(), logging::LogLevel::Info); - } - - let functions_to_analyze = if args.function.is_empty() { - print_warning("No functions specified, analyzing all exported functions..."); - crate::utils::wasm::parse_functions(&wasm_bytes)? - } else { - args.function.clone() - }; - - let mut executor = ContractExecutor::new(wasm_bytes)?; - if let Some(storage_json) = &args.storage { - let storage = parse_storage(storage_json)?; - executor.set_initial_storage(storage)?; - } - - let mut optimizer = crate::profiler::analyzer::GasOptimizer::new(executor); - - print_info(format!( - "\nAnalyzing {} function(s)...", - functions_to_analyze.len() - )); - logging::log_analysis_start("gas optimization"); - - for function_name in &functions_to_analyze { - print_info(format!(" Analyzing function: {}", function_name)); - match optimizer.analyze_function(function_name, args.args.as_deref()) { - Ok(profile) => { - logging::log_display( - format!( - " CPU: {} instructions, Memory: {} bytes, Time: {} ms", - profile.total_cpu, profile.total_memory, profile.wall_time_ms - ), - logging::LogLevel::Info, - ); - print_success(format!( - " CPU: {} instructions, Memory: {} bytes", - profile.total_cpu, profile.total_memory - )); - } - Err(e) => { - print_warning(format!( - " Warning: Failed to analyze function {}: {}", - function_name, e - )); - tracing::warn!(function = function_name, error = %e, "Failed to analyze function"); - } - } - } - logging::log_analysis_complete("gas optimization", functions_to_analyze.len()); - - let contract_path_str = args.contract.to_string_lossy().to_string(); - let report = optimizer.generate_report(&contract_path_str); - // Render in the requested format. JSON exposes structured suggestions, - // per-function hotspots, and metadata; pretty stays markdown. We build a - // dedicated serializable view rather than deriving Serialize across the - // whole profiler graph. - let rendered = match args.format { - crate::cli::args::OutputFormat::Json => { - #[derive(serde::Serialize)] - struct Hotspot<'a> { - function: &'a str, - total_cpu: u64, - total_memory: u64, - } - #[derive(serde::Serialize)] - struct Suggestion<'a> { - category: &'a str, - title: &'a str, - description: &'a str, - estimated_cpu_savings: u64, - estimated_memory_savings: u64, - location: &'a str, - priority: String, - } - #[derive(serde::Serialize)] - struct OptimizeJsonReport<'a> { - contract_path: &'a str, - total_cpu: u64, - total_memory: u64, - potential_cpu_savings: u64, - potential_memory_savings: u64, - suggestions: Vec>, - hotspots: Vec>, - } - - let view = OptimizeJsonReport { - contract_path: &report.contract_path, - total_cpu: report.total_cpu, - total_memory: report.total_memory, - potential_cpu_savings: report.potential_cpu_savings, - potential_memory_savings: report.potential_memory_savings, - suggestions: report - .suggestions - .iter() - .map(|s| Suggestion { - category: &s.category, - title: &s.title, - description: &s.description, - estimated_cpu_savings: s.estimated_cpu_savings, - estimated_memory_savings: s.estimated_memory_savings, - location: &s.location, - priority: s.priority.to_string(), - }) - .collect(), - hotspots: report - .functions - .iter() - .map(|f| Hotspot { - function: &f.name, - total_cpu: f.total_cpu, - total_memory: f.total_memory, - }) - .collect(), - }; - crate::output::to_json_string(&view).map_err(|e| { - DebuggerError::FileError(format!( - "Failed to serialize optimization report as JSON: {}", - e - )) - })? - } - crate::cli::args::OutputFormat::Pretty => optimizer.generate_markdown_report(&report), - }; - - if let Some(output_path) = &args.output { - fs::write(output_path, &rendered).map_err(|e| { - DebuggerError::FileError(format!( - "Failed to write report to {:?}: {}", - output_path, e - )) - })?; - print_success(format!( - "\nOptimization report written to: {:?}", - output_path - )); - logging::log_optimization_report(&output_path.to_string_lossy()); - } else { - logging::log_display(&rendered, logging::LogLevel::Info); - } - - Ok(()) -} - -/// βœ… Execute the profile command (hotspots + suggestions) -pub fn profile(args: ProfileArgs) -> Result<()> { - logging::log_display( - format!("Profiling contract execution: {:?}", args.contract), - logging::LogLevel::Info, - ); - - let wasm_file = crate::utils::wasm::load_wasm(&args.contract) - .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; - let wasm_bytes = wasm_file.bytes; - let wasm_hash = wasm_file.sha256_hash; - - if let Some(expected) = &args.expected_hash { - if expected.to_lowercase() != wasm_hash { - return Err((crate::DebuggerError::ChecksumMismatch( - expected.clone(), - wasm_hash.clone(), - )) - .into()); - } - } - - logging::log_display( - format!("Contract loaded successfully ({} bytes)", wasm_bytes.len()), - logging::LogLevel::Info, - ); - - // Parse args (optional) - let parsed_args = if let Some(args_json) = &args.args { - Some(parse_args(args_json)?) - } else { - None - }; - - // Create executor - let mut executor = ContractExecutor::new(wasm_bytes)?; - - // Initial storage (optional) - if let Some(storage_json) = &args.storage { - let storage = parse_storage(storage_json)?; - executor.set_initial_storage(storage)?; - } - - // Analyze exactly one function (this command focuses on execution hotspots) - let mut optimizer = crate::profiler::analyzer::GasOptimizer::new(executor); - - logging::log_display( - format!("\nRunning function: {}", args.function), - logging::LogLevel::Info, - ); - if let Some(ref a) = parsed_args { - logging::log_display(format!("Args: {}", a), logging::LogLevel::Info); - } - - let _profile = optimizer.analyze_function(&args.function, parsed_args.as_deref())?; - - let contract_path_str = args.contract.to_string_lossy().to_string(); - let report = optimizer.generate_report(&contract_path_str); - - // Format output based on export_format - let output_content = match args.export_format { - crate::cli::args::ProfileExportFormat::FoldedStack => { - // Export in folded stack format for external tools (issue #502) - optimizer.to_folded_stack_format(&report) - } - crate::cli::args::ProfileExportFormat::Json => { - // Export as JSON with basic metrics - let func_names: Vec = report.functions.iter().map(|f| f.name.clone()).collect(); - crate::output::to_json_string(&serde_json::json!({ - "contract": contract_path_str, - "functions": func_names, - "total_cpu": report.total_cpu, - "total_memory": report.total_memory, - "potential_cpu_savings": report.potential_cpu_savings, - "potential_memory_savings": report.potential_memory_savings, - })) - .unwrap_or_else(|_| "{}".to_string()) - } - crate::cli::args::ProfileExportFormat::Report => { - // Default markdown report - let hotspots = report.format_hotspots(); - let markdown = optimizer.generate_markdown_report(&report); - logging::log_display(format!("\n{}", hotspots), logging::LogLevel::Info); - markdown - } - }; - - if let Some(output_path) = &args.output { - fs::write(output_path, &output_content).map_err(|e| { - DebuggerError::FileError(format!( - "Failed to write report to {:?}: {}", - output_path, e - )) - })?; - logging::log_display( - format!("\nProfile report written to: {:?}", output_path), - logging::LogLevel::Info, - ); - } else if !matches!( - args.export_format, - crate::cli::args::ProfileExportFormat::Report - ) { - // Only print output_content for non-Report formats if no file specified - logging::log_display(format!("\n{}", output_content), logging::LogLevel::Info); - } - - Ok(()) -} - -/// Execute the compare command. -pub fn compare(args: CompareArgs) -> Result<()> { - print_info(format!("Loading trace A: {:?}", args.trace_a)); - let trace_a = crate::compare::ExecutionTrace::from_file(&args.trace_a)?; - - print_info(format!("Loading trace B: {:?}", args.trace_b)); - let trace_b = crate::compare::ExecutionTrace::from_file(&args.trace_b)?; - - print_info("Comparing traces..."); - let filters = crate::compare::engine::CompareFilters::new( - args.ignore_path.clone(), - args.ignore_field.clone(), - )?; - let report = crate::compare::CompareEngine::compare_with_filters(&trace_a, &trace_b, &filters); - let rendered = match args.format { - OutputFormat::Json => { - let envelope = crate::output::VersionedOutput::success("compare", &report); - serde_json::to_string_pretty(&envelope).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize comparison report: {}", e)) - })? - } - OutputFormat::Pretty => crate::compare::CompareEngine::render_report(&report), - }; - - if let Some(output_path) = &args.output { - fs::write(output_path, &rendered).map_err(|e| { - DebuggerError::FileError(format!( - "Failed to write report to {:?}: {}", - output_path, e - )) - })?; - print_success(format!("Comparison report written to: {:?}", output_path)); - } else { - println!("{}", rendered); - } - - Ok(()) -} - -/// Execute the replay command. -/// Execute the replay command. -pub fn replay(args: ReplayArgs, verbosity: Verbosity) -> Result<()> { - let is_json = args.format == crate::cli::args::OutputFormat::Json; - if !is_json { - print_info(format!("Loading trace file: {:?}", args.trace_file)); - } - // Fail fast on malformed or unsupported-schema-version traces (#1288). - let raw_trace: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&args.trace_file).map_err(|e| { - DebuggerError::FileError(format!( - "Failed to read trace file {:?}: {}", - args.trace_file, e - )) - })?) - .map_err(|e| { - DebuggerError::FileError(format!( - "Failed to parse trace file {:?} as JSON: {}", - args.trace_file, e - )) - })?; - crate::compare::trace::validate_trace_schema(&raw_trace)?; - let original_trace = crate::compare::ExecutionTrace::from_file(&args.trace_file)?; - - // Determine which contract to use - let contract_path = if let Some(path) = &args.contract { - path.clone() - } else if let Some(contract_str) = &original_trace.contract { - std::path::PathBuf::from(contract_str) - } else { - return Err(DebuggerError::ExecutionError( - "No contract path specified and trace file does not contain contract path".to_string(), - ) - .into()); - }; - - if !is_json { - print_info(format!("Loading contract: {:?}", contract_path)); - } - let wasm_bytes = fs::read(&contract_path).map_err(|e| { - DebuggerError::WasmLoadError(format!( - "Failed to read WASM file at {:?}: {}", - contract_path, e - )) - })?; - - if !is_json { - print_success(format!( - "Contract loaded successfully ({} bytes)", - wasm_bytes.len() - )); - } - - // Extract function and args from trace - let function = original_trace.function.as_ref().ok_or_else(|| { - DebuggerError::ExecutionError("Trace file does not contain function name".to_string()) - })?; - - let args_str = original_trace.args.as_deref(); - - // Determine how many steps to replay - let replay_steps = args.replay_until.unwrap_or(usize::MAX); - let is_partial_replay = args.replay_until.is_some(); - - if !is_json { - if is_partial_replay { - print_info(format!("Replaying up to step {}", replay_steps)); - } else { - print_info("Replaying full execution"); - } - - print_info(format!("Function: {}", function)); - if let Some(a) = args_str { - print_info(format!("Arguments: {}", a)); - } - } - - // Set up initial storage from trace - let initial_storage = if !original_trace.storage.is_empty() { - let storage_json = serde_json::to_string(&original_trace.storage).map_err(|e| { - DebuggerError::StorageError(format!("Failed to serialize trace storage: {}", e)) - })?; - Some(storage_json) - } else { - None - }; - - // Execute the contract - if !is_json { - print_info("\n--- Replaying Execution ---\n"); - } - let mut executor = ContractExecutor::new(wasm_bytes)?; - - if let Some(storage) = initial_storage { - executor.set_initial_storage(storage)?; - } - - let mut engine = DebuggerEngine::new(executor, vec![]); - - logging::log_execution_start(function, args_str); - let replayed_result = engine.execute(function, args_str)?; - - if !is_json { - print_success("\n--- Replay Complete ---\n"); - print_success(format!("Replayed Result: {:?}", replayed_result)); - } - logging::log_execution_complete(&replayed_result); - - // Build execution trace from the replay - let storage_after = engine.executor().get_storage_snapshot()?; - let trace_events = engine.executor().get_events().unwrap_or_default(); - let budget = crate::inspector::budget::BudgetInspector::get_cpu_usage(engine.executor().host()); - - let replayed_trace = build_execution_trace( - function, - &contract_path.to_string_lossy(), - args_str.map(|s| s.to_string()), - &storage_after, - &replayed_result, - budget, - engine.executor(), - &trace_events, - replay_steps, - ); - - // Truncate original_trace's call_sequence if needed to match replay_until - let mut truncated_original = original_trace.clone(); - if truncated_original.call_sequence.len() > replay_steps { - truncated_original.call_sequence.truncate(replay_steps); - } - - // Compare results - if !is_json { - print_info("\n--- Comparison ---"); - } - let report = crate::compare::CompareEngine::compare(&truncated_original, &replayed_trace); - - if is_json { - let envelope = crate::output::VersionedOutput::success("replay", &report); - let rendered = crate::output::to_json_string(&envelope).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize replay report: {}", e)) - })?; - - if let Some(output_path) = &args.output { - std::fs::write(output_path, &rendered).map_err(|e| { - DebuggerError::FileError(format!( - "Failed to write report to {:?}: {}", - output_path, e - )) - })?; - print_success(format!("\nReplay report written to: {:?}", output_path)); - } else { - println!("{}", rendered); - } - } else { - let rendered = crate::compare::CompareEngine::render_report(&report); - - if let Some(output_path) = &args.output { - std::fs::write(output_path, &rendered).map_err(|e| { - DebuggerError::FileError(format!( - "Failed to write report to {:?}: {}", - output_path, e - )) - })?; - print_success(format!("\nReplay report written to: {:?}", output_path)); - } else { - logging::log_display(rendered, logging::LogLevel::Info); - } - } - - if !is_json && verbosity == Verbosity::Verbose { - print_verbose("\n--- Call Sequence (Original) ---"); - for (i, call) in original_trace.call_sequence.iter().enumerate() { - let indent = " ".repeat(call.depth as usize); - if let Some(args) = &call.args { - print_verbose(format!("{}{}. {} ({})", indent, i, call.function, args)); - } else { - print_verbose(format!("{}{}. {}", indent, i, call.function)); - } - - if is_partial_replay && i >= replay_steps { - print_verbose(format!("{}... (stopped at step {})", indent, replay_steps)); - break; - } - } - } - - Ok(()) -} - -/// Start debug server for remote connections -pub fn server(args: ServerArgs) -> Result<()> { - print_info(format!( - "Starting remote debug server on {}:{}", - args.host, args.port - )); - if args.token.is_some() { - print_info("Token authentication enabled"); - } else { - print_info("Token authentication disabled"); - } - // #1262: apply the token-strength policy. Warn by default; reject startup when - // --require-strong-token is set and the token is too short. - match evaluate_token_policy(args.token.as_deref(), args.require_strong_token) { - TokenPolicy::Ok => {} - TokenPolicy::Warn(msg) => print_warning(&msg), - TokenPolicy::Reject(msg) => { - return Err(crate::DebuggerError::InvalidArguments(msg).into()); - } - } - if args.tls_cert.is_some() || args.tls_key.is_some() { - print_info("TLS enabled"); - } else if args.token.is_some() { - print_warning( - "Token authentication is enabled without TLS. Assume traffic is plaintext unless you \ - are using a trusted private network or external TLS termination.", - ); - } - - let server = crate::server::DebugServer::new( - args.host.clone(), - args.token.clone(), - args.tls_cert.as_deref(), - args.tls_key.as_deref(), - args.repeat, - args.storage_filter, - args.show_events, - args.event_filter, - args.mock, - )?; - - tokio::runtime::Runtime::new() - .map_err(|e: std::io::Error| miette::miette!(e)) - .and_then(|rt| rt.block_on(server.run(args.port))) -} - -/// Connect to remote debug server -pub fn remote(args: RemoteArgs, _verbosity: Verbosity) -> Result<()> { - let is_json = args.format == crate::cli::args::OutputFormat::Json; - - if !is_json { - print_info(format!("Connecting to remote debugger at {}", args.remote)); - } - - // Build per-request timeouts, falling back to the general --timeout-ms for - // the specialised classes when the user did not set them explicitly. - let default_ms = args.timeout_ms; - let timeouts = crate::client::RemoteClientConfig::build_timeouts( - default_ms, - args.inspect_timeout_ms, - args.storage_timeout_ms, - ); - - let config = crate::client::RemoteClientConfig { - connect_timeout: std::time::Duration::from_millis(args.connect_timeout_ms), - timeouts, - retry: crate::client::RetryPolicy { - max_attempts: args.retry_attempts, - base_delay: std::time::Duration::from_millis(args.retry_base_delay_ms), - max_delay: std::time::Duration::from_millis(args.retry_max_delay_ms), - }, - tls_cert: args.tls_cert.clone(), - tls_key: args.tls_key.clone(), - tls_ca: args.tls_ca.clone(), - session_label: args.session_label.clone(), - ..Default::default() - }; - - let mut client = - crate::client::RemoteClient::connect_with_config(&args.remote, args.token.clone(), config).map_err(|e| { - // Enrich connect-specific errors with a hint about --connect-timeout-ms so - // the user knows which knob to turn without having to read the docs first. - let msg = e.to_string(); - if msg.contains("Request timed out") || msg.contains("timed out") || msg.contains("Connection refused") || msg.contains("Network/transport error") { - miette::miette!("{}\n\nHint: use --connect-timeout-ms (current: {}ms) to extend the initial TCP connect window, or set SOROBAN_DEBUG_CONNECT_TIMEOUT_MS. See docs/remote-troubleshooting.md for the full diagnostic matrix.", - msg, - args.connect_timeout_ms) - } else { - miette::miette!("{}", msg) - } - })?; - - if !is_json { - if let Some(info) = client.session_info() { - print_info(format!( - "Remote session: {} (created {}, label={})", - info.session_id, - info.created_at, - info.label.as_deref().unwrap_or("") - )); - } - } - - if let Some(contract) = &args.contract { - if !is_json { - print_info(format!("Loading contract: {:?}", contract)); - } - let size = client.load_contract(&contract.to_string_lossy())?; - if !is_json { - print_success(format!("Contract loaded: {} bytes", size)); - } - } - - if let Some(action) = &args.action { - return match action { - RemoteAction::Inspect => { - let (function, step_count, paused, call_stack, pause_reason) = client.inspect()?; - if is_json { - #[derive(serde::Serialize)] - struct InspectJsonResult { - function: Option, - step_count: u64, - paused: bool, - pause_reason: Option, - call_stack: Vec, - } - let result = InspectJsonResult { - function, - step_count, - paused, - pause_reason, - call_stack, - }; - let envelope = crate::output::VersionedOutput::success("remote/inspect", result); - println!("{}", crate::output::to_json_string(&envelope).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize inspect JSON output: {}", e)) - })?); - } else { - println!("Function: {}", function.as_deref().unwrap_or("")); - println!("Step count: {}", step_count); - println!("Paused: {}", paused); - if let Some(reason) = pause_reason { - println!("Pause reason: {}", reason); - } - if !call_stack.is_empty() { - println!("Call stack:"); - for frame in &call_stack { - println!(" {}", frame); - } - } - } - Ok(()) - } - RemoteAction::Storage => { - let storage_json = client.get_storage()?; - if is_json { - let parsed: serde_json::Value = serde_json::from_str(&storage_json).unwrap_or(serde_json::Value::Null); - let envelope = crate::output::VersionedOutput::success("remote/storage", parsed); - println!("{}", crate::output::to_json_string(&envelope).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize storage JSON output: {}", e)) - })?); - } else { - println!("{}", storage_json); - } - Ok(()) - } - RemoteAction::Evaluate(eval_args) => { - let (result, result_type) = - client.evaluate(&eval_args.expression, eval_args.frame_id)?; - if is_json { - #[derive(serde::Serialize)] - struct EvaluateJsonResult { - result: String, - result_type: Option, - } - let envelope = crate::output::VersionedOutput::success("remote/evaluate", EvaluateJsonResult { result, result_type }); - println!("{}", crate::output::to_json_string(&envelope).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize evaluate JSON output: {}", e)) - })?); - } else { - if let Some(rtype) = &result_type { - println!("[{}] {}", rtype, result); - } else { - println!("{}", result); - } - } - Ok(()) - } - }; - } - - if let Some(function) = &args.function { - if is_json { - let result = client.execute(function, args.args.as_deref())?; - #[derive(serde::Serialize)] - struct ExecuteJsonResult { - result: String, - } - let envelope = crate::output::VersionedOutput::success("remote/execute", ExecuteJsonResult { result }); - println!("{}", crate::output::to_json_string(&envelope).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize execute JSON output: {}", e)) - })?); - } else { - print_info(format!("Executing function: {}", function)); - let result = client.execute(function, args.args.as_deref())?; - print_success(format!("Result: {}", result)); - } - return Ok(()); - } - - client.ping()?; - if is_json { - #[derive(serde::Serialize)] - struct PingJsonResult { - reachable: bool, - message: String, - } - let envelope = crate::output::VersionedOutput::success( - "remote/ping", - PingJsonResult { - reachable: true, - message: "Remote debugger is reachable".to_string(), - }, - ); - println!("{}", crate::output::to_json_string(&envelope).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize ping JSON output: {}", e)) - })?); - } else { - print_success("Remote debugger is reachable"); - } - Ok(()) -} -/// Launch interactive debugger UI -pub fn interactive(args: InteractiveArgs, _verbosity: Verbosity) -> Result<()> { - print_info(format!("Loading contract: {:?}", args.contract)); - logging::log_loading_contract(&args.contract.to_string_lossy()); - - let wasm_file = crate::utils::wasm::load_wasm(&args.contract) - .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; - let wasm_bytes = wasm_file.bytes; - let wasm_hash = wasm_file.sha256_hash; - - if let Some(expected) = &args.expected_hash { - if expected.to_lowercase() != wasm_hash { - return Err((crate::DebuggerError::ChecksumMismatch( - expected.clone(), - wasm_hash.clone(), - )) - .into()); - } - } - - print_success(format!( - "Contract loaded successfully ({} bytes)", - wasm_bytes.len() - )); - - if let Some(snapshot_path) = &args.network_snapshot { - print_info(format!("Loading network snapshot: {:?}", snapshot_path)); - logging::log_loading_snapshot(&snapshot_path.to_string_lossy()); - let loader = SnapshotLoader::from_file(snapshot_path)?; - let loaded_snapshot = loader.apply_to_environment()?; - logging::log_display(loaded_snapshot.format_summary(), logging::LogLevel::Info); - } - - let parsed_args = if let Some(args_json) = &args.args { - Some(parse_args(args_json)?) - } else { - None - }; - - let mut initial_storage = if let Some(storage_json) = &args.storage { - Some(parse_storage(storage_json)?) - } else { - None - }; - - if let Some(import_path) = &args.import_storage { - print_info(format!("Importing storage from: {:?}", import_path)); - let imported = crate::inspector::storage::StorageState::import_from_file(import_path)?; - print_success(format!("Imported {} storage entries", imported.len())); - initial_storage = Some(serde_json::to_string(&imported).map_err(|e| { - DebuggerError::StorageError(format!("Failed to serialize imported storage: {}", e)) - })?); - } - - let mut executor = ContractExecutor::new(wasm_bytes.clone())?; - executor.set_timeout(args.timeout); - - if let Some(storage) = initial_storage { - executor.set_initial_storage(storage)?; - } - if !args.mock.is_empty() { - executor.set_mock_specs(&args.mock)?; - } - - let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone()); - - if args.instruction_debug { - print_info("Enabling instruction-level debugging..."); - engine.enable_instruction_debug(&wasm_bytes)?; - - if args.step_instructions { - let step_mode = parse_step_mode(&args.step_mode)?; - engine.start_instruction_stepping(step_mode)?; - } - } - - print_info("Starting interactive session (type 'help' for commands)"); - let mut ui = DebuggerUI::new(engine)?; - ui.queue_execution(args.function.clone(), parsed_args); - ui.run() -} - -/// Launch TUI debugger -pub fn tui(args: TuiArgs, _verbosity: Verbosity) -> Result<()> { - print_info(format!("Loading contract: {:?}", args.contract)); - let wasm_file = crate::utils::wasm::load_wasm(&args.contract) - .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; - let wasm_bytes = wasm_file.bytes; - - print_success(format!( - "Contract loaded successfully ({} bytes)", - wasm_bytes.len() - )); - - if let Some(snapshot_path) = &args.network_snapshot { - print_info(format!("Loading network snapshot: {:?}", snapshot_path)); - logging::log_loading_snapshot(&snapshot_path.to_string_lossy()); - let loader = SnapshotLoader::from_file(snapshot_path)?; - let loaded_snapshot = loader.apply_to_environment()?; - logging::log_display(loaded_snapshot.format_summary(), logging::LogLevel::Info); - } - - let parsed_args = if let Some(args_json) = &args.args { - Some(parse_args(args_json)?) - } else { - None - }; - - let initial_storage = if let Some(storage_json) = &args.storage { - Some(parse_storage(storage_json)?) - } else { - None - }; - - let mut executor = ContractExecutor::new(wasm_bytes.clone())?; - - if let Some(storage) = initial_storage { - executor.set_initial_storage(storage)?; - } - - let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone()); - engine.stage_execution(&args.function, parsed_args.as_deref()); - - run_dashboard(engine, &args.function) -} - -/// Inspect a WASM contract -pub fn inspect(args: InspectArgs, _verbosity: Verbosity) -> Result<()> { - let wasm_file = crate::utils::wasm::load_wasm(&args.contract) - .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; - if let Some(expected) = &args.expected_hash { - if !wasm_file.sha256_hash.eq_ignore_ascii_case(expected) { - return Err(crate::DebuggerError::ChecksumMismatch( - expected.clone(), - wasm_file.sha256_hash.clone(), - ) - .into()); - } - } - - let bytes = wasm_file.bytes; - - if args.source_map_diagnostics { - return inspect_source_map_diagnostics(&args, &bytes); - } - - let info = crate::utils::wasm::get_module_info(&bytes)?; - let artifact_metadata = crate::utils::wasm::extract_wasm_artifact_metadata(&bytes)?; - if args.format == OutputFormat::Json { - let exported_functions = if args.functions { - Some(crate::utils::wasm::parse_function_signatures(&bytes)?) - } else { - None - }; - let result = serde_json::json!({ - "contract": args.contract.display().to_string(), - "size_bytes": info.total_size, - "types": info.type_count, - "functions": info.function_count, - "exports": info.export_count, - "exported_functions": exported_functions, - "artifact_metadata": artifact_metadata, - }); - let envelope = crate::output::VersionedOutput::success("inspect", result); - println!( - "{}", - crate::output::to_json_string(&envelope).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize inspect JSON output: {}", e)) - })? - ); - return Ok(()); - } - - println!("Contract: {:?}", args.contract); - println!("Size: {} bytes", info.total_size); - println!("Types: {}", info.type_count); - println!("Functions: {}", info.function_count); - println!("Exports: {}", info.export_count); - println!("Artifact metadata:"); - println!( - " Build profile hint: {}", - artifact_metadata.build_profile_hint - ); - println!( - " Optimization hint: {}", - artifact_metadata.optimization_hint - ); - println!( - " Name section: {}", - if artifact_metadata.name_section_present { - "present" - } else { - "absent" - } - ); - println!( - " DWARF debug sections: {}", - if artifact_metadata.has_debug_sections { - if artifact_metadata.debug_sections.is_empty() { - "present".to_string() - } else { - format!( - "present ({}, {} bytes)", - artifact_metadata.debug_sections.join(", "), - artifact_metadata.debug_section_bytes - ) - } - } else { - "absent".to_string() - } - ); - if let Some(module_name) = &artifact_metadata.module_name { - println!(" Module name: {}", module_name); - } - if !artifact_metadata.package_hints.is_empty() { - println!(" Package hints:"); - for hint in &artifact_metadata.package_hints { - println!(" - {}", hint); - } - } - if !artifact_metadata.producers.is_empty() { - println!(" Producers:"); - for field in &artifact_metadata.producers { - let values = field - .values - .iter() - .map(|value| { - if value.version.is_empty() { - value.name.clone() - } else { - format!("{} {}", value.name, value.version) - } - }) - .collect::>() - .join(", "); - println!(" {}: {}", field.name, values); - } - } - if !artifact_metadata.heuristic_notes.is_empty() { - println!(" Notes:"); - for note in &artifact_metadata.heuristic_notes { - println!(" - {}", note); - } - } - if args.functions { - let sigs = crate::utils::wasm::parse_function_signatures(&bytes)?; - println!("Exported functions:"); - for sig in &sigs { - let params: Vec = sig - .params - .iter() - .map(|p| format!("{}: {}", p.name, p.type_name)) - .collect(); - let ret = sig.return_type.as_deref().unwrap_or("()"); - println!(" {}({}) -> {}", sig.name, params.join(", "), ret); - } - } - Ok(()) -} - -fn inspect_source_map_diagnostics(args: &InspectArgs, wasm_bytes: &[u8]) -> Result<()> { - let report = - crate::debugger::source_map::SourceMap::inspect_wasm(wasm_bytes, args.source_map_limit)?; - - match args.format { - OutputFormat::Json => { - let output = SourceMapDiagnosticsCommandOutput { - contract: args.contract.display().to_string(), - source_map: report, - }; - let pretty = crate::output::to_json_string(&output).map_err(|e| { - DebuggerError::ExecutionError(format!( - "Failed to serialize source-map diagnostics JSON output: {e}" - )) - })?; - println!("{pretty}"); - } - OutputFormat::Pretty => { - println!("Source Map Diagnostics"); - println!("Contract: {}", args.contract.display()); - println!("Resolved mappings: {}", report.mappings_count); - println!("Fallback mode: {}", report.fallback_mode); - println!("Fallback behavior: {}", report.fallback_message); - - println!("\nDWARF sections:"); - for section in &report.sections { - let status = if section.present { - "present" - } else { - "missing" - }; - println!( - " {}: {} ({} bytes)", - section.name, status, section.size_bytes - ); - } - - if report.preview.is_empty() { - println!("\nResolved mappings preview: none"); - } else { - println!("\nResolved mappings preview:"); - for mapping in &report.preview { - let column = mapping - .location - .column - .map(|column| format!(":{}", column)) - .unwrap_or_default(); - println!( - " 0x{offset:08x} -> {file}:{line}{column}", - offset = mapping.offset, - file = mapping.location.file.display(), - line = mapping.location.line, - column = column - ); - } - } - - if report.diagnostics.is_empty() { - println!("\nDiagnostics: none"); - } else { - println!("\nDiagnostics:"); - for diagnostic in &report.diagnostics { - println!(" - {}", diagnostic.message); - } - } - } - } - - Ok(()) -} - -/// Run symbolic execution analysis -pub fn symbolic(args: SymbolicArgs, _verbosity: Verbosity) -> Result<()> { - print_info(format!("Loading contract: {:?}", args.contract)); - let wasm_file = crate::utils::wasm::load_wasm(&args.contract) - .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; - - let analyzer = SymbolicAnalyzer::new(); - let config = symbolic_config_from_args(&args)?; - let report = analyzer.analyze_with_config(&wasm_file.bytes, &args.function, &config)?; - - match args.format { - OutputFormat::Pretty => { - println!("{}", render_symbolic_report(&report)); - } - OutputFormat::Json => { - let envelope = crate::output::VersionedOutput::success("symbolic", &report); - println!( - "{}", - crate::output::to_json_string(&envelope).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize symbolic report: {}", e)) - })? - ); - } - } - - if let Some(output_path) = &args.output { - let scenario_toml = analyzer.generate_scenario_toml(&report); - fs::write(output_path, scenario_toml).map_err(|e| { - DebuggerError::FileError(format!( - "Failed to write symbolic scenario to {:?}: {}", - output_path, e - )) - })?; - print_success(format!("Scenario TOML written to: {:?}", output_path)); - } - - if let Some(bundle_path) = &args.export_replay_bundle { - let bundle = build_replay_bundle( - &config, - &report, - wasm_file.sha256_hash.clone(), - Some(args.contract.to_string_lossy().to_string()), - ); - let serialized = crate::output::to_json_string(&bundle).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize replay bundle to JSON: {}", e)) - })?; - fs::write(bundle_path, serialized).map_err(|e| { - DebuggerError::FileError(format!( - "Failed to write replay bundle to {:?}: {}", - bundle_path, e - )) - })?; - print_success(format!("Replay bundle written to: {:?}", bundle_path)); - } - - Ok(()) -} - -/// Analyze a contract -pub fn analyze(args: AnalyzeArgs, _verbosity: Verbosity) -> Result<()> { - print_info(format!("Loading contract: {:?}", args.contract)); - let wasm_file = crate::utils::wasm::load_wasm(&args.contract) - .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; - - let mut dynamic_analysis = None; - let mut warnings = Vec::new(); - let mut executor = None; - let mut trace_entries = None; - - if let Some(function) = &args.function { - let mut dynamic_executor = ContractExecutor::new(wasm_file.bytes.clone())?; - dynamic_executor.enable_mock_all_auths(); - dynamic_executor.set_timeout(args.timeout); - - if let Some(storage_json) = &args.storage { - dynamic_executor.set_initial_storage(parse_storage(storage_json)?)?; - } - - let parsed_args = if let Some(args_json) = &args.args { - Some(parse_args(args_json)?) - } else { - None - }; - - match dynamic_executor.execute(function, parsed_args.as_deref()) { - Ok(result) => { - let trace = dynamic_executor.get_dynamic_trace().unwrap_or_default(); - - dynamic_analysis = Some(DynamicAnalysisMetadata { - function: function.clone(), - args: parsed_args.clone(), - result: Some(result), - trace_entries: trace.len(), - }); - trace_entries = Some(trace); - executor = Some(dynamic_executor); - } - Err(err) => { - warnings.push(format!( - "Dynamic analysis for function '{}' failed: {}", - function, err - )); - } - } - } - - let mut analyzer = SecurityAnalyzer::new(); - let config = crate::config::Config::load_or_default(); - if let Some(supp_path) = config.output.suppressions_file { - if std::path::Path::new(&supp_path).exists() { - analyzer = analyzer.load_suppressions_from_file(&supp_path)?; - } - } - // Get registered rule IDs for validation - let registered_rules: Vec = analyzer - .get_rules() - .iter() - .map(|rule| rule.id().to_string()) - .collect(); - - // Validate rule IDs - validate_rule_ids(&args.enable_rule, &args.disable_rule, ®istered_rules)?; - - let filter = crate::analyzer::security::AnalyzerFilter { - enable_rules: args.enable_rule.clone(), - disable_rules: args.disable_rule.clone(), - min_severity: convert_min_severity(args.min_severity), - }; - let contract_path = args.contract.to_string_lossy().to_string(); - let report = analyzer.analyze( - &wasm_file.bytes, - executor.as_ref(), - trace_entries.as_deref(), - &filter, - &contract_path, - )?; - let output = AnalyzeCommandOutput { - findings: report.findings, - rules: report.rules.into_iter().collect(), - dynamic_analysis, - warnings, - suppressed_count: report.metadata.suppressed_count, - }; - - match args.format.to_lowercase().as_str() { - "text" => println!("{}", render_security_report(&output)), - "json" => { - let envelope = crate::output::VersionedOutput::success("analyze", &output); - println!( - "{}", - crate::output::to_json_string(&envelope).map_err(|e| { - DebuggerError::FileError(format!("Failed to serialize analysis output: {}", e)) - })? - ); - } - other => { - return Err(DebuggerError::InvalidArguments(format!( - "Unsupported --format '{}'. Use 'text' or 'json'.", - other - )) - .into()); - } - } - - Ok(()) -} - -#[derive(Debug, Clone, serde::Serialize)] -struct DoctorCheck { - ok: bool, - message: String, -} - -#[derive(Debug, Clone, serde::Serialize)] -struct RemoteDoctorReport { - address: String, - connect: DoctorCheck, - handshake: Option, - ping: Option, - auth: Option, - selected_protocol: Option, -} - -#[derive(Debug, Clone, serde::Serialize)] -struct DoctorReport { - binary: serde_json::Value, - config: serde_json::Value, - history: serde_json::Value, - plugins: serde_json::Value, - protocol: serde_json::Value, - remote: Option, - vscode_extension: serde_json::Value, -} - -fn check_ok(message: impl Into) -> DoctorCheck { - DoctorCheck { - ok: true, - message: message.into(), - } -} - -fn check_err(message: impl Into) -> DoctorCheck { - DoctorCheck { - ok: false, - message: message.into(), - } -} - -fn env_truthy(name: &str) -> bool { - std::env::var(name) - .ok() - .is_some_and(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) -} - -fn read_repo_vscode_extension_version(manifest_path: Option<&PathBuf>) -> Option { - let path = manifest_path.cloned().unwrap_or_else(|| { - PathBuf::from("extensions") - .join("vscode") - .join("package.json") - }); - let text = std::fs::read_to_string(path).ok()?; - let v: serde_json::Value = serde_json::from_str(&text).ok()?; - v.get("version")?.as_str().map(|s| s.to_string()) -} - -fn compute_default_history_path() -> Result { - if let Ok(path) = std::env::var("SOROBAN_DEBUG_HISTORY_FILE") { - return Ok(PathBuf::from(path)); - } - - let home_dir = std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .map_err(|_| DebuggerError::FileError("Could not determine home directory".to_string()))?; - Ok(PathBuf::from(home_dir) - .join(".soroban-debug") - .join("history.json")) -} - -fn history_file_status(path: &PathBuf) -> serde_json::Value { - let exists = path.exists(); - let metadata = std::fs::metadata(path).ok(); - let size = metadata.as_ref().map(|m| m.len()); - - let readable = std::fs::File::open(path).is_ok(); - let writable = std::fs::OpenOptions::new().append(true).open(path).is_ok(); - - serde_json::json!({ - "path": path, - "exists": exists, - "size_bytes": size, - "readable": readable || !exists, - "writable": writable || !exists, - }) -} - -fn config_status() -> serde_json::Value { - let path = std::path::Path::new(crate::config::DEFAULT_CONFIG_FILE).to_path_buf(); - let exists = path.exists(); - let load = crate::config::Config::load(); - let parse_ok = load.is_ok() || !exists; - let error = load.err().map(|e| e.to_string()); - - serde_json::json!({ - "path": path, - "exists": exists, - "parse_ok": parse_ok, - "error": error, - }) -} - -fn plugin_status() -> serde_json::Value { - let disabled = env_truthy("SOROBAN_DEBUG_NO_PLUGINS"); - let plugin_dir = crate::plugin::PluginLoader::default_plugin_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| "".to_string()); - - let discovered = crate::plugin::PluginLoader::default_plugin_dir() - .map(|dir| crate::plugin::PluginLoader::new(dir).discover_plugins()) - .unwrap_or_default(); - - let registry = crate::plugin::registry::init_global_plugin_registry(); - let stats = registry.read().map(|r| r.statistics()).unwrap_or_default(); - - serde_json::json!({ - "disabled_via_env": disabled, - "plugin_dir": plugin_dir, - "discovered_manifests": discovered.len(), - "loaded_plugins": stats.total, - "provides_commands": stats.provides_commands, - "provides_formatters": stats.provides_formatters, - "supports_hot_reload": stats.supports_hot_reload, - }) -} - -fn protocol_status() -> serde_json::Value { - serde_json::json!({ - "min": crate::server::protocol::PROTOCOL_MIN_VERSION, - "max": crate::server::protocol::PROTOCOL_MAX_VERSION, - "current": crate::server::protocol::PROTOCOL_VERSION, - }) -} - -fn binary_status() -> serde_json::Value { - serde_json::json!({ - "name": env!("CARGO_PKG_NAME"), - "version": env!("CARGO_PKG_VERSION"), - "os": std::env::consts::OS, - "arch": std::env::consts::ARCH, - }) -} - -fn vscode_extension_status(vscode_manifest: Option<&PathBuf>) -> serde_json::Value { - let version = read_repo_vscode_extension_version(vscode_manifest); - serde_json::json!({ - "version_hint": version, - "wire_protocol_expected_min": crate::server::protocol::PROTOCOL_MIN_VERSION, - "wire_protocol_expected_max": crate::server::protocol::PROTOCOL_MAX_VERSION, - }) -} - -/// Run a scenario -pub fn scenario(args: ScenarioArgs, _verbosity: Verbosity) -> Result<()> { - crate::scenario::run_scenario(args, _verbosity) -} - -/// Launch the REPL -pub async fn repl(args: ReplArgs) -> Result<()> { - print_info(format!("Loading contract: {:?}", args.contract)); - let wasm_file = crate::utils::wasm::load_wasm(&args.contract) - .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; - crate::utils::wasm::verify_wasm_hash(&wasm_file.sha256_hash, args.expected_hash.as_ref())?; - - if args.expected_hash.is_some() { - print_verbose("Checksum verified βœ“"); - } - - crate::repl::start_repl(ReplConfig { - contract_path: args.contract, - network_snapshot: args.network_snapshot, - storage: args.storage, - watch_keys: args.watch_keys, - }) - .await -} - -/// Show budget trend chart -pub fn show_budget_trend( - contract: Option<&str>, - function: Option<&str>, - regression: crate::history::RegressionConfig, -) -> Result<()> { - let manager = HistoryManager::new()?; - let mut records = manager.filter_history(contract, function)?; - - crate::history::sort_records_by_date(&mut records); - - if records.is_empty() { - if !Formatter::is_quiet() { - println!("Budget Trend"); - println!( - "Filters: contract={} function={}", - contract.unwrap_or("*"), - function.unwrap_or("*") - ); - println!("No run history found yet."); - println!("Tip: run `soroban-debug run ...` a few times to generate history."); - } - return Ok(()); - } - - let stats = budget_trend_stats_or_err(&records)?; - let cpu_values: Vec = records.iter().map(|r| r.cpu_used).collect(); - let mem_values: Vec = records.iter().map(|r| r.memory_used).collect(); - - if !Formatter::is_quiet() { - println!("Budget Trend"); - println!( - "Filters: contract={} function={}", - contract.unwrap_or("*"), - function.unwrap_or("*") - ); - println!( - "Regression params: threshold>{:.1}% lookback={} smoothing={}", - regression.threshold_pct, regression.lookback, regression.smoothing_window - ); - println!( - "Runs: {} Range: {} -> {}", - stats.count, stats.first_date, stats.last_date - ); - println!( - "CPU insns: last={} avg={} min={} max={}", - crate::inspector::budget::BudgetInspector::format_cpu_insns(stats.last_cpu), - crate::inspector::budget::BudgetInspector::format_cpu_insns(stats.cpu_avg), - crate::inspector::budget::BudgetInspector::format_cpu_insns(stats.cpu_min), - crate::inspector::budget::BudgetInspector::format_cpu_insns(stats.cpu_max) - ); - println!( - "Mem bytes: last={} avg={} min={} max={}", - crate::inspector::budget::BudgetInspector::format_memory_bytes(stats.last_mem), - crate::inspector::budget::BudgetInspector::format_memory_bytes(stats.mem_avg), - crate::inspector::budget::BudgetInspector::format_memory_bytes(stats.mem_min), - crate::inspector::budget::BudgetInspector::format_memory_bytes(stats.mem_max) - ); - println!(); - println!("CPU trend: {}", Formatter::sparkline(&cpu_values, 50)); - println!("MEM trend: {}", Formatter::sparkline(&mem_values, 50)); - - if let Some((cpu_reg, mem_reg)) = - crate::history::check_regression_with_config(&records, ®ression) - { - if cpu_reg > 0.0 || mem_reg > 0.0 { - println!(); - println!("Regression warning (latest vs baseline):"); - if cpu_reg > 0.0 { - println!(" CPU increased by {:.1}%", cpu_reg); - } - if mem_reg > 0.0 { - println!(" Memory increased by {:.1}%", mem_reg); - } - } - } - } - - Ok(()) -} - -/// Prune run history according to retention policy. -pub fn history_prune(args: HistoryPruneArgs) -> Result<()> { - let policy = crate::history::RetentionPolicy { - max_records: args.max_records, - max_age_days: args.max_age_days, - }; - - if policy.is_empty() { - if !Formatter::is_quiet() { - println!("No retention policy specified. Use --max-records and/or --max-age-days."); - } - return Ok(()); - } - - let manager = HistoryManager::new()?; - - if args.dry_run { - let mut records = manager.load_history()?; - let before = records.len(); - HistoryManager::apply_retention(&mut records, &policy); - let remaining = records.len(); - let removed = before.saturating_sub(remaining); - - if !Formatter::is_quiet() { - if removed == 0 { - println!("[dry-run] Nothing removed ({} records).", remaining); - } else { - println!( - "[dry-run] Would remove {} record(s). {} record(s) remaining.", - removed, remaining - ); - } - } - return Ok(()); - } - - let report = manager.prune_history(&policy)?; - if !Formatter::is_quiet() { - if report.removed == 0 { - println!("Nothing removed ({} records).", report.remaining); - } else { - println!( - "Removed {} record(s). {} record(s) remaining.", - report.removed, report.remaining - ); - } - } - Ok(()) -} - -pub fn doctor(args: crate::cli::args::DoctorArgs) -> Result<()> { - let history_path = compute_default_history_path() - .unwrap_or_else(|_| std::env::temp_dir().join("soroban-debug-history.json")); - - let remote = if let Some(addr) = args.remote.as_ref() { - let config = crate::client::remote_client::RemoteClientConfig { - connect_timeout: std::time::Duration::from_millis(args.timeout_ms), - timeouts: crate::client::remote_client::RemoteClientConfig::build_timeouts( - args.timeout_ms, - Some(args.timeout_ms), - Some(args.timeout_ms), - ), - ..crate::client::remote_client::RemoteClientConfig::default() - }; - - match crate::client::remote_client::RemoteClient::connect_with_config( - addr, - args.token.clone(), - config, - ) { - Ok(mut client) => { - let ping = match client.ping() { - Ok(_) => Some(check_ok("Ping succeeded")), - Err(e) => Some(check_err(format!("Ping failed: {}", e))), - }; - Some(RemoteDoctorReport { - address: addr.clone(), - connect: check_ok(format!("Connected to {}", addr)), - handshake: Some(check_ok("Handshake succeeded")), - ping, - auth: args - .token - .as_ref() - .map(|_| check_ok("Authentication succeeded")), - selected_protocol: None, - }) - } - Err(e) => Some(RemoteDoctorReport { - address: addr.clone(), - connect: check_err(format!("Connection failed: {}", e)), - handshake: None, - ping: None, - auth: None, - selected_protocol: None, - }), - } - } else { - None - }; - - let report = DoctorReport { - binary: binary_status(), - config: config_status(), - history: history_file_status(&history_path), - plugins: plugin_status(), - protocol: protocol_status(), - remote, - vscode_extension: vscode_extension_status(args.vscode_manifest.as_ref()), - }; - - if args.format == OutputFormat::Json { - let json = crate::output::to_json_string(&report) - .map_err(|e| miette::miette!("Failed to serialize doctor report: {}", e))?; - println!("{}", json); - return Ok(()); - } - - println!("Binary: {}", report.binary); - println!("Config: {}", report.config); - println!("History: {}", report.history); - println!("Plugins: {}", report.plugins); - println!("Protocol: {}", report.protocol); - if let Some(remote) = report.remote { - println!("Remote connect: {}", remote.connect.message); - if let Some(handshake) = remote.handshake { - println!("Remote handshake: {}", handshake.message); - } - if let Some(ping) = remote.ping { - println!("Remote ping: {}", ping.message); - } - if let Some(auth) = remote.auth { - println!("Remote auth: {}", auth.message); - } - } - println!("VS Code extension: {}", report.vscode_extension); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn budget_trend_stats_or_err_returns_error_instead_of_panicking() { - let empty: Vec = Vec::new(); - let err = budget_trend_stats_or_err(&empty).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("Failed to compute budget trend statistics")); - } - - #[test] - fn doctor_report_serializes_with_expected_sections() { - let history_path = std::env::temp_dir().join("soroban-debug-doctor-history.json"); - let report = DoctorReport { - binary: binary_status(), - config: config_status(), - history: history_file_status(&history_path), - plugins: plugin_status(), - protocol: protocol_status(), - remote: None, - vscode_extension: vscode_extension_status(None), - }; - - let json = serde_json::to_value(&report).unwrap(); - assert!(json.get("binary").is_some()); - assert!(json.get("config").is_some()); - assert!(json.get("history").is_some()); - assert!(json.get("plugins").is_some()); - assert!(json.get("protocol").is_some()); - assert!(json.get("vscode_extension").is_some()); - } -} -// -/////// - assert!(json.get("binary").is_some()); - assert!(json.get("config").is_some()); - assert!(json.get("history").is_some()); - assert!(json.get("plugins").is_some()); - assert!(json.get("protocol").is_some()); - assert!(json.get("vscode_extension").is_some()); - } -} -// -/////// +use crate::analyzer::symbolic::SymbolicConfig; +use crate::analyzer::upgrade::{CompatibilityReport, ExecutionDiff, UpgradeAnalyzer}; +use crate::analyzer::{ + security::SecurityAnalyzer, + symbolic::{build_replay_bundle, SymbolicAnalyzer}, +}; +use crate::cli::args::{ + AnalyzeArgs, CompareArgs, HistoryPruneArgs, InspectArgs, InteractiveArgs, OptimizeArgs, + OutputFormat, ProfileArgs, RemoteAction, RemoteArgs, ReplArgs, ReplayArgs, RunArgs, + ScenarioArgs, ServerArgs, SymbolicArgs, SymbolicProfile, TuiArgs, UpgradeCheckArgs, Verbosity, +}; +use crate::cli::output::write_json_pretty_file; +use crate::debugger::engine::DebuggerEngine; +use crate::debugger::instruction_pointer::StepMode; +use crate::debugger::timeline::{ + TimelineDeltas, TimelineExport, TimelinePausePoint, TimelineRunInfo, TimelineStorageDelta, + TimelineWarning, TIMELINE_EXPORT_SCHEMA_VERSION, +}; +use crate::history::{HistoryManager, RunHistory}; +use crate::inspector::events::{ContractEvent, EventInspector}; +use crate::logging; +use crate::output::OutputWriter; +use crate::repeat::RepeatRunner; +use crate::repl::ReplConfig; +use crate::runtime::executor::ContractExecutor; +use crate::simulator::SnapshotLoader; +use crate::ui::formatter::Formatter; +use crate::ui::{run_dashboard, DebuggerUI}; +use crate::{DebuggerError, Result}; +use miette::WrapErr; +use std::fs; +use std::path::PathBuf; + +fn print_info(message: impl AsRef) { + if !Formatter::is_quiet() { + println!("{}", Formatter::info(message)); + } +} + +fn print_success(message: impl AsRef) { + if !Formatter::is_quiet() { + println!("{}", Formatter::success(message)); + } +} + +fn print_warning(message: impl AsRef) { + if !Formatter::is_quiet() { + println!("{}", Formatter::warning(message)); + } +} + +/// Print the final contract return value β€” always shown regardless of verbosity. +fn print_result(message: impl AsRef) { + if !Formatter::is_quiet() { + println!("{}", Formatter::success(message)); + } +} + +/// Print verbose-only detail β€” only shown when --verbose is active. +fn print_verbose(message: impl AsRef) { + if Formatter::is_verbose() { + println!("{}", Formatter::info(message)); + } +} + +fn budget_trend_stats_or_err(records: &[RunHistory]) -> Result { + crate::history::budget_trend_stats(records).ok_or_else(|| { + DebuggerError::ExecutionError( + "Failed to compute budget trend statistics for the selected dataset".to_string(), + ) + .into() + }) +} + +#[derive(serde::Serialize)] +struct DynamicAnalysisMetadata { + function: String, + args: Option, + result: Option, + trace_entries: usize, +} + +#[derive(serde::Serialize)] +struct AnalyzeCommandOutput { + findings: Vec, + /// Rule metadata keyed by rule id (#1272). Lets downstream tools resolve a + /// finding's `rule_id` to stable id/name/severity/category/remediation + /// fields for filtering. A BTreeMap keeps the JSON ordering deterministic. + rules: std::collections::BTreeMap, + dynamic_analysis: Option, + warnings: Vec, + suppressed_count: usize, +} + +#[derive(serde::Serialize)] +struct SourceMapDiagnosticsCommandOutput { + contract: String, + source_map: crate::debugger::source_map::SourceMapInspectionReport, +} + +fn render_symbolic_report(report: &crate::analyzer::symbolic::SymbolicReport) -> String { + let mut lines = vec![ + format!("Function: {}", report.function), + format!("Paths explored: {}", report.paths_explored), + format!("Panics found: {}", report.panics_found), + format!( + "Replay token: {}", + report + .metadata + .seed + .map(|seed| seed.to_string()) + .unwrap_or_else(|| "none".to_string()) + ), + format!( + "Budget: path_cap={}, input_combination_cap={}, timeout={}s", + report.metadata.config.max_paths, + report.metadata.config.max_input_combinations, + report.metadata.config.timeout_secs + ), + format!( + "Input combinations: generated={}, attempted={}, distinct_paths={}", + report.metadata.generated_input_combinations, + report.metadata.attempted_input_combinations, + report.metadata.distinct_paths_recorded + ), + format!( + "Coverage: {:.1}% (explored branch/function coverage)", + report.metadata.coverage_fraction * 100.0 + ), + ]; + + if !report.metadata.uncovered_regions.is_empty() { + lines.push(format!( + "Uncovered regions: {}", + report.metadata.uncovered_regions.join(", ") + )); + } + + if report.metadata.truncation_reasons.is_empty() { + lines.push("Truncation: none".to_string()); + } else { + lines.push(format!( + "Truncation: {}", + report.metadata.truncation_reasons.join("; ") + )); + } + + if report.paths.is_empty() { + lines.push("No distinct execution paths were discovered.".to_string()); + return lines.join("\n"); + } + + lines.push(String::new()); + lines.push("Distinct paths:".to_string()); + + for (idx, path) in report.paths.iter().enumerate() { + let outcome = match (&path.return_value, &path.panic) { + (Some(value), _) => format!("return {}", value), + (_, Some(panic)) => format!("panic {}", panic), + _ => "unknown".to_string(), + }; + lines.push(format!( + " {}. inputs={} -> {}", + idx + 1, + path.inputs, + outcome + )); + } + + lines.join("\n") +} + +fn symbolic_profile_config(profile: SymbolicProfile) -> SymbolicConfig { + match profile { + SymbolicProfile::Fast => SymbolicConfig::fast(), + SymbolicProfile::Balanced => SymbolicConfig::balanced(), + SymbolicProfile::Deep => SymbolicConfig::deep(), + } +} + +fn symbolic_config_from_args(args: &SymbolicArgs) -> Result { + let mut config = symbolic_profile_config(args.profile); + if let Some(path_cap) = args.path_cap { + config.max_paths = path_cap; + } + if let Some(input_cap) = args.input_combination_cap { + config.max_input_combinations = input_cap; + } + if let Some(max_breadth) = args.max_breadth { + config.max_breadth = max_breadth; + } + if let Some(timeout) = args.timeout { + config.timeout_secs = timeout; + } + config.seed = args.seed.or(args.replay); + if let Some(storage_seed_path) = &args.storage_seed { + config.storage_seed = Some(fs::read_to_string(storage_seed_path).map_err(|e| { + DebuggerError::FileError(format!( + "Failed to read storage seed file {:?}: {}", + storage_seed_path, e + )) + })?); + } + + Ok(config) +} + +/// Convert MinSeverity enum to analyzer Severity enum. +fn convert_min_severity(value: crate::cli::args::MinSeverity) -> crate::analyzer::security::Severity { + match value { + crate::cli::args::MinSeverity::Low => crate::analyzer::security::Severity::Low, + crate::cli::args::MinSeverity::Medium => crate::analyzer::security::Severity::Medium, + crate::cli::args::MinSeverity::High => crate::analyzer::security::Severity::High, + } +} + +/// Find the closest matching rule IDs using Levenshtein distance. +fn suggest_rule_ids(unknown: &str, known_rules: &[String], max_distance: usize) -> Vec { + use std::cmp; + + // Calculate Levenshtein distance between two strings + let levenshtein = |a: &str, b: &str| { + let a_len = a.len(); + let b_len = b.len(); + let mut matrix = vec![vec![0; b_len + 1]; a_len + 1]; + + for i in 0..=a_len { + matrix[i][0] = i; + } + for j in 0..=b_len { + matrix[0][j] = j; + } + + for (i, a_char) in a.chars().enumerate() { + for (j, b_char) in b.chars().enumerate() { + let cost = if a_char == b_char { 0 } else { 1 }; + matrix[i + 1][j + 1] = cmp::min( + cmp::min( + matrix[i][j + 1] + 1, // deletion + matrix[i + 1][j] + 1, // insertion + ), + matrix[i][j] + cost, // substitution + ); + } + } + matrix[a_len][b_len] + }; + + let mut suggestions: Vec<_> = known_rules + .iter() + .map(|rule| { + let distance = levenshtein(&unknown.to_lowercase(), &rule.to_lowercase()); + (distance, rule.clone()) + }) + .filter(|(distance, _)| *distance <= max_distance) + .collect(); + + suggestions.sort_by_key(|(distance, _)| *distance); + suggestions.into_iter().map(|(_, rule)| rule).collect() +} + +/// Validate rule IDs in enable_rules and disable_rules lists. +fn validate_rule_ids( + enable_rules: &[String], + disable_rules: &[String], + registered_rules: &[String], +) -> Result<()> { + let mut invalid_rules = Vec::new(); + + // Check enable_rules + for rule in enable_rules { + if !registered_rules.contains(rule) { + invalid_rules.push(("enable", rule.clone())); + } + } + + // Check disable_rules + for rule in disable_rules { + if !registered_rules.contains(rule) { + invalid_rules.push(("disable", rule.clone())); + } + } + + if !invalid_rules.is_empty() { + let mut message = String::from("Invalid rule IDs provided:\n"); + for (filter_type, rule) in &invalid_rules { + message.push_str(&format!(" --{}-rule '{}': not found\n", filter_type, rule)); + let suggestions = suggest_rule_ids(rule, registered_rules, 2); + if !suggestions.is_empty() { + message.push_str(&format!(" Did you mean: {}?\n", suggestions.join(", "))); + } + } + message.push_str(&format!("\nAvailable rules: {}\n", registered_rules.join(", "))); + return Err(DebuggerError::InvalidArguments(message).into()); + } + + Ok(()) +} + +fn render_security_report(output: &AnalyzeCommandOutput) -> String { + let mut lines = Vec::new(); + + if let Some(dynamic) = &output.dynamic_analysis { + lines.push(format!("Dynamic analysis function: {}", dynamic.function)); + if let Some(args) = &dynamic.args { + lines.push(format!("Dynamic analysis args: {}", args)); + } + if let Some(result) = &dynamic.result { + lines.push(format!("Dynamic execution result: {}", result)); + } + lines.push(format!( + "Dynamic trace entries captured: {}", + dynamic.trace_entries + )); + lines.push(String::new()); + } + + if !output.warnings.is_empty() { + lines.push("Warnings:".to_string()); + for warning in &output.warnings { + lines.push(format!(" - {}", warning)); + } + lines.push(String::new()); + } + + if output.findings.is_empty() { + lines.push("No security findings detected.".to_string()); + if output.suppressed_count > 0 { + lines.push(format!( + "({} findings were suppressed)", + output.suppressed_count + )); + } + return lines.join("\n"); + } + + lines.push(format!( + "Findings: {} ({} suppressed)", + output.findings.len(), + output.suppressed_count + )); + for (idx, finding) in output.findings.iter().enumerate() { + lines.push(format!( + " {}. [{:?}] {} at {}", + idx + 1, + finding.severity, + finding.rule_id, + finding.location + )); + lines.push(format!(" {}", finding.description)); + if let Some(confidence) = finding.confidence { + lines.push(format!(" Confidence: {:.0}%", confidence * 100.0)); + } + if let Some(rationale) = &finding.rationale { + lines.push(format!(" Rationale: {}", rationale)); + } + lines.push(format!(" Remediation: {}", finding.remediation)); + } + + lines.join("\n") +} + +/// Run instruction-level stepping mode. +fn run_instruction_stepping( + engine: &mut DebuggerEngine, + function: &str, + args: Option<&str>, +) -> Result<()> { + logging::log_display( + "\n=== Instruction Stepping Mode ===", + logging::LogLevel::Info, + ); + logging::log_display( + "Type 'help' for available commands\n", + logging::LogLevel::Info, + ); + + display_instruction_context(engine, 3); + + loop { + print!("(step) > "); + std::io::Write::flush(&mut std::io::stdout()) + .map_err(|e| DebuggerError::IoError(format!("Failed to flush stdout: {}", e)))?; + + let mut input = String::new(); + let bytes_read = std::io::stdin() + .read_line(&mut input) + .map_err(|e| DebuggerError::IoError(format!("Failed to read line: {}", e)))?; + if bytes_read == 0 { + logging::log_display("Input stream closed.", logging::LogLevel::Info); + break; + } + + let input = input.trim().to_lowercase(); + let cmd = input.as_str(); + + let result = match cmd { + "n" | "next" | "s" | "step" | "into" | "" => engine.step_into(), + "o" | "over" => engine.step_over(), + "u" | "out" => engine.step_out(), + "b" | "block" => engine.step_block(), + "p" | "prev" | "back" => engine.step_back(), + "c" | "continue" => { + logging::log_display("Continuing execution...", logging::LogLevel::Info); + engine.continue_execution()?; + let res = engine.execute_without_breakpoints(function, args)?; + logging::log_display( + format!("Execution completed. Result: {:?}", res), + logging::LogLevel::Info, + ); + break; + } + "i" | "info" => { + display_instruction_info(engine); + continue; + } + "ctx" | "context" => { + display_instruction_context(engine, 5); + continue; + } + "h" | "help" => { + logging::log_display(Formatter::format_stepping_help(), logging::LogLevel::Info); + continue; + } + "q" | "quit" | "exit" => { + logging::log_display( + "Exiting instruction stepping mode...", + logging::LogLevel::Info, + ); + break; + } + _ => { + logging::log_display( + format!("Unknown command: {cmd}. Type 'help' for available commands."), + logging::LogLevel::Info, + ); + continue; + } + }; + + match result { + Ok(true) => display_instruction_context(engine, 3), + Ok(false) => { + let msg = if matches!(cmd, "p" | "prev" | "back") { + "Cannot step back: no previous instruction" + } else { + "Cannot step: execution finished or error occurred" + }; + logging::log_display(msg, logging::LogLevel::Info); + } + Err(e) => { + logging::log_display(format!("Error stepping: {}", e), logging::LogLevel::Info) + } + } + } + + Ok(()) +} + +fn display_instruction_context(engine: &DebuggerEngine, context_size: usize) { + let context = engine.get_instruction_context(context_size); + let formatted = Formatter::format_instruction_context(&context, context_size); + logging::log_display(formatted, logging::LogLevel::Info); +} + +fn display_instruction_info(engine: &DebuggerEngine) { + if let Ok(state) = engine.state().lock() { + let ip = state.instruction_pointer(); + let step_mode = if ip.is_stepping() { + Some(ip.step_mode()) + } else { + None + }; + + logging::log_display( + Formatter::format_instruction_pointer_state( + ip.current_index(), + ip.call_stack_depth(), + step_mode, + ip.is_stepping(), + ), + logging::LogLevel::Info, + ); + logging::log_display( + Formatter::format_instruction_stats( + state.instructions().len(), + ip.current_index(), + state.step_count(), + ), + logging::LogLevel::Info, + ); + + if let Some(inst) = state.current_instruction() { + logging::log_display( + format!( + "Current Instruction: {} (Offset: 0x{:08x}, Local index: {}, Control flow: {})", + inst.name(), + inst.offset, + inst.local_index, + inst.is_control_flow() + ), + logging::LogLevel::Info, + ); + } + } else { + logging::log_display("Cannot access debug state", logging::LogLevel::Info); + } +} + +/// Parse a step mode from its textual form. The single source of truth for +/// step-mode parsing across the run and interactive flows (#1263). Unsupported +/// modes return a clear error instead of silently defaulting, so a typo can't +/// quietly change stepping behaviour. +fn parse_step_mode(mode: &str) -> Result { + match mode.trim().to_lowercase().as_str() { + "into" | "i" => Ok(StepMode::StepInto), + "over" | "o" => Ok(StepMode::StepOver), + "out" | "u" => Ok(StepMode::StepOut), + "block" | "b" => Ok(StepMode::StepBlock), + other => Err(crate::DebuggerError::InvalidArguments(format!( + "unsupported step mode '{other}'. Supported modes: into, over, out, block." + )) + .into()), + } +} + +/// Recommended/required minimum length for a remote debug auth token (#1262). +const MIN_REMOTE_TOKEN_LEN: usize = 16; + +/// Outcome of the remote-debug token-strength policy (#1262). +#[derive(Debug, PartialEq, Eq)] +enum TokenPolicy { + Ok, + Warn(String), + Reject(String), +} + +/// Evaluate the token-strength policy for the remote debug server (#1262). +/// A token shorter than [`MIN_REMOTE_TOKEN_LEN`] warns by default, or is +/// rejected when `require_strong` is set. No token is allowed (auth disabled). +fn evaluate_token_policy(token: Option<&str>, require_strong: bool) -> TokenPolicy { + match token { + None => TokenPolicy::Ok, + Some(t) if t.trim().len() >= MIN_REMOTE_TOKEN_LEN => TokenPolicy::Ok, + Some(_) => { + let msg = format!( + "Remote debug token is shorter than {MIN_REMOTE_TOKEN_LEN} characters. \ + Prefer at least {MIN_REMOTE_TOKEN_LEN} characters, ideally a random 32-byte token." + ); + if require_strong { + TokenPolicy::Reject(format!( + "{msg} Refusing to start because --require-strong-token is set." + )) + } else { + TokenPolicy::Warn(msg) + } + } + } +} + +#[cfg(test)] +mod step_and_token_tests { + use super::*; + use crate::debugger::instruction_pointer::StepMode; + + #[test] + fn parse_step_mode_accepts_supported_modes_and_aliases() { + assert_eq!(parse_step_mode("into").unwrap(), StepMode::StepInto); + assert_eq!(parse_step_mode("OVER").unwrap(), StepMode::StepOver); + assert_eq!(parse_step_mode(" out ").unwrap(), StepMode::StepOut); + assert_eq!(parse_step_mode("b").unwrap(), StepMode::StepBlock); + } + + #[test] + fn parse_step_mode_rejects_unsupported_mode() { + let err = parse_step_mode("sideways").unwrap_err().to_string(); + assert!(err.contains("unsupported step mode"), "got: {err}"); + assert!(err.contains("sideways"), "got: {err}"); + } + + #[test] + fn token_policy_ok_when_absent_or_long_enough() { + assert_eq!(evaluate_token_policy(None, true), TokenPolicy::Ok); + assert_eq!( + evaluate_token_policy(Some("0123456789abcdef"), true), + TokenPolicy::Ok + ); + } + + #[test] + fn token_policy_warns_by_default_for_short_token() { + assert!(matches!( + evaluate_token_policy(Some("short"), false), + TokenPolicy::Warn(_) + )); + } + + #[test] + fn token_policy_rejects_short_token_when_enforcement_enabled() { + assert!(matches!( + evaluate_token_policy(Some("short"), true), + TokenPolicy::Reject(_) + )); + } +} + +/// Display mock call log +fn display_mock_call_log(calls: &[crate::runtime::executor::MockCallEntry]) { + if calls.is_empty() { + return; + } + print_info("\n--- Mock Contract Calls ---"); + for (i, entry) in calls.iter().enumerate() { + let status = if entry.mocked { "MOCKED" } else { "REAL" }; + print_info(format!( + "{}. {} {} (args: {}) -> {}", + i + 1, + status, + entry.function, + entry.args_count, + if entry.returned.is_some() { + "returned" + } else { + "pending" + } + )); + } +} + +/// Execute batch mode with parallel execution +fn run_batch(args: &RunArgs, batch_file: &std::path::Path) -> Result<()> { + let contract = args + .contract + .as_ref() + .expect("contract is required for batch mode"); + let function = args + .function + .as_ref() + .expect("function is required for batch mode"); + + print_info(format!("Loading contract: {:?}", contract)); + logging::log_loading_contract(&contract.to_string_lossy()); + + let wasm_bytes = fs::read(contract).map_err(|e| { + DebuggerError::WasmLoadError(format!("Failed to read WASM file at {:?}: {}", contract, e)) + })?; + + print_success(format!( + "Contract loaded successfully ({} bytes)", + wasm_bytes.len() + )); + logging::log_contract_loaded(wasm_bytes.len()); + + print_info(format!("Loading batch file: {:?}", batch_file)); + let batch_items = crate::batch::BatchExecutor::load_batch_file(batch_file)?; + print_success(format!("Loaded {} test cases", batch_items.len())); + + if let Some(snapshot_path) = &args.network_snapshot { + print_info(format!("\nLoading network snapshot: {:?}", snapshot_path)); + logging::log_loading_snapshot(&snapshot_path.to_string_lossy()); + let loader = SnapshotLoader::from_file(snapshot_path)?; + let loaded_snapshot = loader.apply_to_environment()?; + logging::log_display(loaded_snapshot.format_summary(), logging::LogLevel::Info); + } + + print_info(format!( + "\nExecuting {} test cases in parallel for function: {}", + batch_items.len(), + function + )); + logging::log_execution_start(function, None); + + let executor = crate::batch::BatchExecutor::new(wasm_bytes, function.clone())?; + let results = executor.execute_batch(batch_items)?; + let summary = crate::batch::BatchExecutor::summarize(&results); + + crate::batch::BatchExecutor::display_results(&results, &summary); + + if args.is_json_output() { + let output = serde_json::json!({ + "results": results, + "summary": summary, + }); + logging::log_display( + crate::output::to_json_string(&output).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize output: {}", e)) + })?, + logging::LogLevel::Info, + ); + } + + logging::log_execution_complete(&format!("{}/{} passed", summary.passed, summary.total)); + + if summary.failed > 0 || summary.errors > 0 { + return Err(DebuggerError::ExecutionError(format!( + "Batch execution completed with failures: {} failed, {} errors", + summary.failed, summary.errors + )) + .into()); + } + + Ok(()) +} + +/// Execute the run command. +#[tracing::instrument(skip_all, fields(contract = ?args.contract, function = args.function))] +pub fn run(args: RunArgs, verbosity: Verbosity) -> Result<()> { + // Start debug server if requested + if args.server { + return server(ServerArgs { + host: args.host, + port: args.port, + token: args.token, + require_strong_token: false, + tls_cert: args.tls_cert, + tls_key: args.tls_key, + repeat: args.repeat, + storage_filter: args.storage_filter, + show_events: args.show_events, + event_filter: args.event_filter, + mock: args.mock, + }); + } + + // Remote execution/ping path. + if let Some(remote_addr) = &args.remote { + return remote( + RemoteArgs { + remote: remote_addr.clone(), + token: args.token.clone(), + contract: args.contract.clone(), + function: args.function.clone(), + tls_cert: args.tls_cert.clone(), + tls_key: args.tls_key.clone(), + tls_ca: None, + session_label: None, + args: args.args.clone(), + connect_timeout_ms: 10000, + timeout_ms: 30000, + inspect_timeout_ms: None, + storage_timeout_ms: None, + retry_attempts: 3, + retry_base_delay_ms: 200, + retry_max_delay_ms: 2000, + format: if args.is_json_output() { crate::cli::args::OutputFormat::Json } else { crate::cli::args::OutputFormat::Pretty }, + action: None, + }, + verbosity, + ); + } + + // Initialize output writer + let mut output_writer = OutputWriter::new(args.save_output.as_deref(), args.append)?; + + // Handle batch execution mode + if let Some(batch_file) = &args.batch_args { + return run_batch(&args, batch_file); + } + + if args.dry_run { + return run_dry_run(&args); + } + + let contract = args + .contract + .as_ref() + .expect("contract is required for run"); + let function = args + .function + .as_ref() + .expect("function is required for run"); + + print_info(format!("Loading contract: {:?}", contract)); + output_writer.write(&format!("Loading contract: {:?}", contract))?; + logging::log_loading_contract(&contract.to_string_lossy()); + + let wasm_file = crate::utils::wasm::load_wasm(contract) + .with_context(|| format!("Failed to read WASM file: {:?}", contract))?; + let wasm_bytes = wasm_file.bytes; + let wasm_hash = wasm_file.sha256_hash; + + if let Some(expected) = &args.expected_hash { + if expected.to_lowercase() != wasm_hash { + return Err((crate::DebuggerError::ChecksumMismatch( + expected.clone(), + wasm_hash.clone(), + )) + .into()); + } + } + + print_success(format!( + "Contract loaded successfully ({} bytes)", + wasm_bytes.len() + )); + output_writer.write(&format!( + "Contract loaded successfully ({} bytes)", + wasm_bytes.len() + ))?; + + if args.verbose || verbosity == Verbosity::Verbose { + print_verbose(format!("SHA-256: {}", wasm_hash)); + output_writer.write(&format!("SHA-256: {}", wasm_hash))?; + if args.expected_hash.is_some() { + print_verbose("Checksum verified βœ“"); + output_writer.write("Checksum verified βœ“")?; + } + } + + logging::log_contract_loaded(wasm_bytes.len()); + + if let Some(snapshot_path) = &args.network_snapshot { + print_info(format!("\nLoading network snapshot: {:?}", snapshot_path)); + output_writer.write(&format!("Loading network snapshot: {:?}", snapshot_path))?; + logging::log_loading_snapshot(&snapshot_path.to_string_lossy()); + let loader = SnapshotLoader::from_file(snapshot_path)?; + let loaded_snapshot = loader.apply_to_environment()?; + output_writer.write(&loaded_snapshot.format_summary())?; + logging::log_display(loaded_snapshot.format_summary(), logging::LogLevel::Info); + } + + let parsed_args = if let Some(args_json) = &args.args { + Some(parse_args(args_json)?) + } else { + None + }; + + let mut initial_storage = if let Some(storage_json) = &args.storage { + Some(parse_storage(storage_json)?) + } else { + None + }; + + // Import storage if specified + if let Some(import_path) = &args.import_storage { + print_info(format!("Importing storage from: {:?}", import_path)); + let imported = crate::inspector::storage::StorageState::import_from_file(import_path)?; + print_success(format!("Imported {} storage entries", imported.len())); + initial_storage = Some(serde_json::to_string(&imported).map_err(|e| { + DebuggerError::StorageError(format!("Failed to serialize imported storage: {}", e)) + })?); + } + + if let Some(n) = args.repeat { + logging::log_repeat_execution(function, n as usize); + let runner = RepeatRunner::new(wasm_bytes, args.breakpoint, initial_storage); + let stats = runner.run(function, parsed_args.as_deref(), n)?; + stats.display(); + return Ok(()); + } + + print_info("\nStarting debugger..."); + output_writer.write("Starting debugger...")?; + print_info(format!("Function: {}", function)); + output_writer.write(&format!("Function: {}", function))?; + if let Some(ref parsed) = parsed_args { + print_info(format!("Arguments: {}", parsed)); + output_writer.write(&format!("Arguments: {}", parsed))?; + } + logging::log_execution_start(function, parsed_args.as_deref()); + + let mut executor = ContractExecutor::new(wasm_bytes.clone())?; + executor.set_timeout(args.timeout); + + if let Some(storage) = initial_storage { + executor.set_initial_storage(storage)?; + } + if !args.mock.is_empty() { + executor.set_mock_specs(&args.mock)?; + } + + let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone()); + + if args.instruction_debug { + print_info("Enabling instruction-level debugging..."); + engine.enable_instruction_debug(&wasm_bytes)?; + + if args.step_instructions { + let step_mode = parse_step_mode(&args.step_mode)?; + print_info(format!( + "Starting instruction stepping in '{}' mode", + args.step_mode + )); + engine.start_instruction_stepping(step_mode)?; + run_instruction_stepping(&mut engine, function, parsed_args.as_deref())?; + return Ok(()); + } + } + + print_info("\n--- Execution Start ---\n"); + output_writer.write("\n--- Execution Start ---\n")?; + let storage_before = engine.executor().get_storage_snapshot()?; + let result = engine.execute(function, parsed_args.as_deref())?; + let storage_after = engine.executor().get_storage_snapshot()?; + print_success("\n--- Execution Complete ---\n"); + output_writer.write("\n--- Execution Complete ---\n")?; + print_result(format!("Result: {:?}", result)); + output_writer.write(&format!("Result: {:?}", result))?; + logging::log_execution_complete(&result); + + // Generate test if requested + if let Some(test_path) = &args.generate_test { + if let Some(record) = engine.executor().last_execution() { + print_info(format!("\nGenerating unit test: {:?}", test_path)); + let test_code = crate::codegen::TestGenerator::generate(record, contract)?; + crate::codegen::TestGenerator::write_to_file(test_path, &test_code, args.overwrite)?; + print_success(format!( + "Unit test generated successfully at {:?}", + test_path + )); + } else { + print_warning("No execution record found to generate test."); + } + } + + let storage_diff = crate::inspector::storage::StorageInspector::compute_diff( + &storage_before, + &storage_after, + &args.alert_on_change, + ); + if !storage_diff.is_empty() || !args.alert_on_change.is_empty() { + print_info("\n--- Storage Changes ---"); + crate::inspector::storage::StorageInspector::display_diff(&storage_diff); + } + + let mock_calls = engine.executor().get_mock_call_log(); + if !args.mock.is_empty() { + display_mock_call_log(&mock_calls); + } + + // Save budget info to history + let host = engine.executor().host(); + let budget = crate::inspector::budget::BudgetInspector::get_cpu_usage(host); + if let Ok(manager) = HistoryManager::new() { + let record = RunHistory { + date: chrono::Utc::now().to_rfc3339(), + contract_hash: contract.to_string_lossy().to_string(), + function: function.clone(), + cpu_used: budget.cpu_instructions, + memory_used: budget.memory_bytes, + }; + let _ = manager.append_record(record); + } + let _json_memory_summary = engine.executor().last_memory_summary().cloned(); + + // Export storage if specified + if let Some(export_path) = &args.export_storage { + print_info(format!("Exporting storage to: {:?}", export_path)); + let storage_snapshot = engine.executor().get_storage_snapshot()?; + crate::inspector::storage::StorageState::export_to_file(&storage_snapshot, export_path)?; + print_success(format!( + "Exported {} storage entries", + storage_snapshot.len() + )); + } + + let mut json_events = None; + if args.show_events || !args.event_filter.is_empty() || args.filter_topic.is_some() { + print_info("\n--- Events ---"); + + // Attempt to read raw events from executor + let raw_events = engine.executor().get_events()?; + + // Convert runtime event objects into our inspector::events::ContractEvent via serde translation. + // This is a generic, safe conversion as long as runtime events are serializable with sensible fields. + let converted_events: Vec = + match serde_json::to_value(&raw_events).and_then(serde_json::from_value) { + Ok(evts) => evts, + Err(e) => { + // If conversion fails, fall back to attempting to stringify each raw event for display. + print_warning(format!( + "Failed to convert runtime events for structured display: {}", + e + )); + // Fallback: attempt a best-effort stringification + let fallback: Vec = raw_events + .into_iter() + .map(|r| ContractEvent { + contract_id: None, + topics: vec![], + data: format!("{:?}", r), + }) + .collect(); + fallback + } + }; + + // Determine filter: prefer repeatable --event-filter, fallback to legacy --filter-topic + let filter_opt = if !args.event_filter.is_empty() { + Some(args.event_filter.join(",")) + } else { + args.filter_topic.clone() + }; + + let filtered_events = if let Some(ref filt) = filter_opt { + EventInspector::filter_events(&converted_events, filt) + } else { + converted_events.clone() + }; + + if filtered_events.is_empty() { + print_warning("No events captured."); + } else { + // Display events in readable form + let lines = EventInspector::format_events(&filtered_events); + for line in &lines { + print_info(line); + } + } + + json_events = Some(filtered_events); + } + + if !args.storage_filter.is_empty() { + let storage_filter = crate::inspector::storage::StorageFilter::new(&args.storage_filter) + .map_err(|e| DebuggerError::StorageError(format!("Invalid storage filter: {}", e)))?; + + print_info("\n--- Storage ---"); + let inspector = + crate::inspector::storage::StorageInspector::with_state(storage_after.clone()); + inspector.display_filtered(&storage_filter); + } + + let mut json_auth = None; + if args.show_auth { + let auth_tree = engine.executor().get_auth_tree()?; + if args.json { + // JSON mode: print the auth tree inline (will also be included in + // the combined JSON object further below). + let json_output = crate::inspector::auth::AuthInspector::to_json(&auth_tree)?; + logging::log_display(json_output, logging::LogLevel::Info); + } else { + print_info("\n--- Authorization Tree ---"); + crate::inspector::auth::AuthInspector::display_with_summary(&auth_tree); + } + json_auth = Some(auth_tree); + } + + let mut json_ledger = None; + if args.show_ledger { + print_info("\n--- Ledger Entries ---"); + let mut ledger_inspector = crate::inspector::ledger::LedgerEntryInspector::new(); + ledger_inspector.set_ttl_warning_threshold(args.ttl_warning_threshold); + + match engine.executor_mut().finish() { + Ok((footprint, storage)) => { + #[allow(clippy::clone_on_copy)] + let mut footprint_map = std::collections::HashMap::new(); + for (k, v) in &footprint.0 { + #[allow(clippy::clone_on_copy)] + footprint_map.insert(k.clone(), v.clone()); + footprint_map.insert(k.clone(), *v); + } + + for (key, val_opt) in &storage.map { + if let Some(access_type) = footprint_map.get(key) { + if let Some((entry, ttl)) = val_opt { + let key_str = format!("{:?}", **key); + let storage_type = + if key_str.contains("Temporary") || key_str.contains("temporary") { + crate::inspector::ledger::StorageType::Temporary + } else if key_str.contains("Instance") + || key_str.contains("instance") + || key_str.contains("LedgerKeyContractInstance") + { + crate::inspector::ledger::StorageType::Instance + } else { + crate::inspector::ledger::StorageType::Persistent + }; + + use soroban_env_host::storage::AccessType; + let is_read = true; // Everything in the footprint is at least read + let is_write = matches!(*access_type, AccessType::ReadWrite); + + ledger_inspector.add_entry( + format!("{:?}", **key), + format!("{:?}", **entry), + storage_type, + ttl.unwrap_or(0), + is_read, + is_write, + ); + } + } + } + } + Err(e) => { + print_warning(format!("Failed to extract ledger footprint: {}", e)); + } + } + + ledger_inspector.display(); + ledger_inspector.display_warnings(); + json_ledger = Some(ledger_inspector); + } + + if args.is_json_output() { + let mut result_obj = serde_json::json!({ + "result": result, + "sha256": wasm_hash, + "budget": { + "cpu_instructions": budget.cpu_instructions, + "memory_bytes": budget.memory_bytes, + }, + "storage_diff": storage_diff, + }); + + if let Some(ref events) = json_events { + result_obj["events"] = EventInspector::to_json_value(events); + } + if let Some(auth_tree) = json_auth { + result_obj["auth"] = crate::inspector::auth::AuthInspector::to_json_value(&auth_tree); + } + if !mock_calls.is_empty() { + result_obj["mock_calls"] = serde_json::Value::Array( + mock_calls + .iter() + .map(|entry| { + serde_json::json!({ + "contract_id": entry.contract_id, + "function": entry.function, + "args_count": entry.args_count, + "mocked": entry.mocked, + "returned": entry.returned, + }) + }) + .collect(), + ); + } + if let Some(ref ledger) = json_ledger { + result_obj["ledger_entries"] = ledger.to_json(); + } + + let output = crate::output::VersionedOutput::success("run", result_obj); + + match crate::output::to_json_string(&output) { + Ok(json) => println!("{}", json), + Err(e) => { + let err_output = crate::output::VersionedOutput::::error( + "run", + format!("Failed to serialize output: {}", e), + ); + if let Ok(err_json) = crate::output::to_json_string(&err_output) { + println!("{}", err_json); + } + } + } + } + + if let Some(trace_path) = &args.trace_output { + print_info(format!("\nExporting execution trace to: {:?}", trace_path)); + + let args_str = parsed_args + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_default()); + + let trace_events = json_events + .clone() + .unwrap_or_else(|| engine.executor().get_events().unwrap_or_default()); + + let trace = build_execution_trace( + function, + contract.to_string_lossy().as_ref(), + args_str, + &storage_after, + &result, + budget.clone(), + engine.executor(), + &trace_events, + usize::MAX, + ); + + if let Ok(json) = trace.to_json() { + if let Err(e) = std::fs::write(trace_path, json) { + print_warning(format!("Failed to write trace to {:?}: {}", trace_path, e)); + } else { + print_success(format!("Successfully exported trace to {:?}", trace_path)); + if let Err(e) = + export_replay_artifact_manifest(&trace, trace_path, contract.as_ref(), &args) + { + print_warning(format!( + "Failed to write replay artifact manifest for {:?}: {}", + trace_path, e + )); + } + } + } + } + + if let Some(timeline_path) = &args.timeline_output { + print_info(format!( + "\nExporting timeline narrative to: {:?}", + timeline_path + )); + + let stack_summary = engine + .state() + .lock() + .ok() + .map(|state| state.call_stack().get_stack().to_vec()) + .unwrap_or_default(); + + let mut warnings = Vec::new(); + if !storage_diff.triggered_alerts.is_empty() { + warnings.push(TimelineWarning { + kind: "storage_alert".to_string(), + message: format!( + "Triggered storage alert(s): {}", + storage_diff.triggered_alerts.join(", ") + ), + }); + } + + let events_count = json_events + .as_ref() + .map(|ev| ev.len()) + .or_else(|| engine.executor().get_events().ok().map(|ev| ev.len())); + + let storage_delta = if storage_diff.is_empty() { + None + } else { + Some(TimelineStorageDelta::from_storage_diff(&storage_diff, 200)) + }; + + let mut pauses = Vec::new(); + // Record the actual classified pause reason (breakpoint / step_boundary / + // panic / end_of_execution / user_interrupt) from engine state rather than + // hardcoding "breakpoint" (#1264), so the exported timeline explains why + // execution paused. + let classified_reason = engine + .state() + .lock() + .ok() + .and_then(|s| s.pause_reason()) + .map(|r| r.as_str().to_string()); + if let Some(reason) = classified_reason { + pauses.push(TimelinePausePoint { + index: 0, + reason, + location: None, + call_stack: stack_summary.clone(), + }); + } else if engine.is_paused() && args.breakpoint.iter().any(|bp| bp == function) { + // Paused at the entry breakpoint without a classified reason (prior behavior). + pauses.push(TimelinePausePoint { + index: 0, + reason: "breakpoint".to_string(), + location: None, + call_stack: stack_summary.clone(), + storage_mutation, + }); + } + + let export = TimelineExport { + schema_version: TIMELINE_EXPORT_SCHEMA_VERSION, + created_at: chrono::Utc::now().to_rfc3339(), + run: TimelineRunInfo { + contract_path: contract.to_string_lossy().to_string(), + wasm_sha256: Some(wasm_hash.clone()), + function: function.to_string(), + args_json: args.args.clone(), + result: Some(result.clone()), + error: None, + budget: Some(budget.clone()), + events_count, + }, + pauses, + stack_summary, + deltas: TimelineDeltas { + storage: storage_delta, + }, + warnings, + }; + + if let Err(e) = write_json_pretty_file(timeline_path, &export) { + print_warning(format!( + "Failed to write timeline narrative to {:?}: {}", + timeline_path, e + )); + } else { + print_success(format!( + "Successfully exported timeline narrative to {:?}", + timeline_path + )); + } + } + + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn build_execution_trace( + function: &str, + contract_path: &str, + args_str: Option, + storage_after: &std::collections::HashMap, + result: &str, + budget: crate::inspector::budget::BudgetInfo, + executor: &ContractExecutor, + events: &[crate::inspector::events::ContractEvent], + replay_until: usize, +) -> crate::compare::ExecutionTrace { + let mut trace_storage = std::collections::BTreeMap::new(); + for (k, v) in storage_after { + if let Ok(val) = serde_json::from_str(v) { + trace_storage.insert(k.clone(), val); + } else { + trace_storage.insert(k.clone(), serde_json::Value::String(v.clone())); + } + } + + let return_val = serde_json::from_str(result) + .unwrap_or_else(|_| serde_json::Value::String(result.to_string())); + + let mut call_sequence = Vec::new(); + let mut depth = 0; + + call_sequence.push(crate::compare::trace::CallEntry { + function: function.to_string(), + args: args_str.clone(), + depth, + }); + + if let Ok(diag_events) = executor.get_diagnostic_events() { + for event in diag_events { + // Stop building trace if we hit the replay limit + if call_sequence.len() >= replay_until { + break; + } + + let event_str = format!("{:?}", event); + if event_str.contains("ContractCall") + || (event_str.contains("call") && event.contract_id.is_some()) + { + depth += 1; + call_sequence.push(crate::compare::trace::CallEntry { + function: "nested_call".to_string(), + args: None, + depth, + }); + } else if (event_str.contains("ContractReturn") || event_str.contains("return")) + && depth > 0 + { + depth -= 1; + } + } + } + + let mut trace_events = Vec::new(); + for e in events { + trace_events.push(crate::compare::trace::EventEntry { + contract_id: e.contract_id.clone(), + topics: e.topics.clone(), + data: Some(e.data.clone()), + }); + } + + crate::compare::ExecutionTrace { + label: Some(format!("Execution of {} on {}", function, contract_path)), + contract: Some(contract_path.to_string()), + function: Some(function.to_string()), + args: args_str, + storage: trace_storage, + budget: Some(crate::compare::trace::BudgetTrace { + cpu_instructions: budget.cpu_instructions, + memory_bytes: budget.memory_bytes, + cpu_limit: None, + memory_limit: None, + }), + return_value: Some(return_val), + call_sequence, + events: trace_events, + } +} + +fn export_replay_artifact_manifest( + trace: &crate::compare::ExecutionTrace, + trace_path: &std::path::Path, + contract_path: &std::path::Path, + args: &RunArgs, +) -> Result<()> { + let manifest_path = crate::compare::ExecutionTrace::manifest_path_for_trace(trace_path); + let mut manifest = trace.to_replay_artifact_manifest(trace_path); + + manifest.files.push(crate::output::ReplayArtifactFile { + kind: crate::output::ReplayArtifactKind::Manifest, + path: manifest_path.display().to_string(), + description: Some("Replay artifact manifest".to_string()), + compression: None, + }); + manifest.files.push(crate::output::ReplayArtifactFile { + kind: crate::output::ReplayArtifactKind::ContractWasm, + path: contract_path.display().to_string(), + description: Some("Contract WASM used to generate the trace".to_string()), + compression: None, + }); + + if let Some(path) = &args.network_snapshot { + manifest.files.push(crate::output::ReplayArtifactFile { + kind: crate::output::ReplayArtifactKind::NetworkSnapshot, + path: path.display().to_string(), + description: Some("Network snapshot loaded before execution".to_string()), + compression: None, + }); + } + if let Some(path) = &args.import_storage { + manifest.files.push(crate::output::ReplayArtifactFile { + kind: crate::output::ReplayArtifactKind::StorageImport, + path: path.display().to_string(), + description: Some("Imported storage seed used before execution".to_string()), + compression: None, + }); + } + if let Some(path) = &args.export_storage { + manifest.files.push(crate::output::ReplayArtifactFile { + kind: crate::output::ReplayArtifactKind::StorageExport, + path: path.display().to_string(), + description: Some("Exported storage state captured after execution".to_string()), + compression: None, + }); + } + if let Some(path) = &args.save_output { + manifest.files.push(crate::output::ReplayArtifactFile { + kind: crate::output::ReplayArtifactKind::OutputReport, + path: path.display().to_string(), + description: Some("Saved command output for this run".to_string()), + compression: None, + }); + } + if let Some(path) = &args.generate_test { + manifest.files.push(crate::output::ReplayArtifactFile { + kind: crate::output::ReplayArtifactKind::GeneratedTest, + path: path.display().to_string(), + description: Some("Generated reproduction test derived from the trace".to_string()), + compression: None, + }); + } + + crate::history::write_json_atomically(&manifest_path, &manifest)?; + print_success(format!( + "Replay artifact manifest written to {:?}", + manifest_path + )); + Ok(()) +} + +/// Execute run command in dry-run mode. +fn run_dry_run(args: &RunArgs) -> Result<()> { + let contract = args + .contract + .as_ref() + .expect("contract is required for dry-run"); + print_info(format!("[DRY RUN] Loading contract: {:?}", contract)); + + let wasm_file = crate::utils::wasm::load_wasm(contract) + .with_context(|| format!("Failed to read WASM file: {:?}", contract))?; + let wasm_bytes = wasm_file.bytes; + let wasm_hash = wasm_file.sha256_hash; + + if let Some(expected) = &args.expected_hash { + if expected.to_lowercase() != wasm_hash { + return Err((crate::DebuggerError::ChecksumMismatch( + expected.clone(), + wasm_hash.clone(), + )) + .into()); + } + } + + print_success(format!( + "[DRY RUN] Contract loaded successfully ({} bytes)", + wasm_bytes.len() + )); + + if args.verbose { + print_verbose(format!("[DRY RUN] SHA-256: {}", wasm_hash)); + if args.expected_hash.is_some() { + print_verbose("[DRY RUN] Checksum verified βœ“"); + } + } + + print_info("[DRY RUN] Skipping execution"); + + Ok(()) +} + +/// Get instruction counts from the debugger engine +#[allow(dead_code)] +fn get_instruction_counts( + engine: &DebuggerEngine, +) -> Option { + // Try to get instruction counts from the executor + engine.executor().get_instruction_counts().ok() +} + +/// Display instruction counts per function in a formatted table +#[allow(dead_code)] +fn display_instruction_counts(counts: &crate::runtime::executor::InstructionCounts) { + if counts.function_counts.is_empty() { + return; + } + + print_info("\n--- Instruction Count per Function ---"); + + // Calculate percentages + let percentages: Vec = counts + .function_counts + .iter() + .map(|(_, count)| { + if counts.total > 0 { + ((*count as f64) / (counts.total as f64)) * 100.0 + } else { + 0.0 + } + }) + .collect(); + + // Find max widths for alignment + let max_func_width = counts + .function_counts + .iter() + .map(|(name, _)| name.len()) + .max() + .unwrap_or(20); + let max_count_width = counts + .function_counts + .iter() + .map(|(_, count)| count.to_string().len()) + .max() + .unwrap_or(10); + + // Print header + let header = format!( + "{:width2$} | {:>width3$}", + "Function", + "Instructions", + "Percentage", + width1 = max_func_width, + width2 = max_count_width, + width3 = 10 + ); + print_info(&header); + print_info("-".repeat(header.len())); + + // Print rows + for ((func_name, count), percentage) in counts.function_counts.iter().zip(percentages.iter()) { + let row = format!( + "{:width2$} | {:>7.2}%", + func_name, + count, + percentage, + width1 = max_func_width, + width2 = max_count_width + ); + print_info(&row); + } +} + +/// Execute the upgrade-check command +pub fn upgrade_check(args: UpgradeCheckArgs) -> Result<()> { + print_info(format!("Loading old contract: {:?}", args.old)); + let old_wasm = fs::read(&args.old) + .map_err(|e| miette::miette!("Failed to read old WASM file {:?}: {}", args.old, e))?; + + print_info(format!("Loading new contract: {:?}", args.new)); + let new_wasm = fs::read(&args.new) + .map_err(|e| miette::miette!("Failed to read new WASM file {:?}: {}", args.new, e))?; + + // Optionally run test inputs against both versions + let execution_diffs = if let Some(inputs_json) = &args.test_inputs { + run_test_inputs(inputs_json, &old_wasm, &new_wasm)? + } else { + Vec::new() + }; + + let old_path = args.old.to_string_lossy().to_string(); + let new_path = args.new.to_string_lossy().to_string(); + + let report = + UpgradeAnalyzer::analyze(&old_wasm, &new_wasm, &old_path, &new_path, execution_diffs)?; + + let output = match args.output.as_str() { + "json" => { + let envelope = crate::output::VersionedOutput::success("upgrade-check", &report); + crate::output::to_json_string(&envelope) + .map_err(|e| miette::miette!("Failed to serialize report: {}", e))? + } + _ => format_text_report(&report), + }; + + if let Some(out_file) = &args.output_file { + fs::write(out_file, &output) + .map_err(|e| miette::miette!("Failed to write report to {:?}: {}", out_file, e))?; + print_success(format!("Report written to {:?}", out_file)); + } else { + println!("{}", output); + } + + if !report.is_compatible { + return Err(miette::miette!( + "Contracts are not compatible: {} breaking change(s) detected", + report.breaking_changes.len() + )); + } + + Ok(()) +} + +/// Run test inputs against both WASM versions and collect diffs +fn run_test_inputs( + inputs_json: &str, + old_wasm: &[u8], + new_wasm: &[u8], +) -> Result> { + let inputs: serde_json::Map = serde_json + ::from_str(inputs_json) + .map_err(|e| + miette::miette!( + "Invalid --test-inputs JSON (expected an object mapping function names to arg arrays): {}", + e + ) + )?; + + let mut diffs = Vec::new(); + + for (func_name, args_val) in &inputs { + let args_str = args_val.to_string(); + + let old_result = invoke_wasm(old_wasm, func_name, &args_str); + let new_result = invoke_wasm(new_wasm, func_name, &args_str); + + let outputs_match = old_result == new_result; + diffs.push(ExecutionDiff { + function: func_name.clone(), + args: args_str, + old_result, + new_result, + outputs_match, + }); + } + + Ok(diffs) +} + +/// Invoke a function on a WASM contract and return a string representation of the result +fn invoke_wasm(wasm: &[u8], function: &str, args: &str) -> String { + match ContractExecutor::new(wasm.to_vec()) { + Err(e) => format!("Err(executor: {})", e), + Ok(executor) => { + let mut engine = DebuggerEngine::new(executor, vec![]); + let parsed = if args == "null" || args == "[]" { + None + } else { + Some(args.to_string()) + }; + match engine.execute(function, parsed.as_deref()) { + Ok(val) => format!("Ok({:?})", val), + Err(e) => format!("Err({})", e), + } + } + } +} + +/// Format a compatibility report as human-readable text +fn format_text_report(report: &CompatibilityReport) -> String { + let mut out = String::new(); + + out.push_str("Contract Upgrade Compatibility Report\n"); + out.push_str("======================================\n"); + out.push_str(&format!("Old: {}\n", report.old_wasm_path)); + out.push_str(&format!("New: {}\n", report.new_wasm_path)); + out.push('\n'); + + let status = if report.is_compatible { + "COMPATIBLE" + } else { + "INCOMPATIBLE" + }; + out.push_str(&format!( + "Status: {} (Classification: {})\n", + status, report.classification + )); + + out.push('\n'); + out.push_str(&format!( + "Breaking Changes ({}):\n", + report.breaking_changes.len() + )); + if report.breaking_changes.is_empty() { + out.push_str(" (none)\n"); + } else { + for change in &report.breaking_changes { + out.push_str(&format!(" {}\n", change)); + } + } + + out.push('\n'); + out.push_str(&format!( + "Non-Breaking Changes ({}):\n", + report.non_breaking_changes.len() + )); + if report.non_breaking_changes.is_empty() { + out.push_str(" (none)\n"); + } else { + for change in &report.non_breaking_changes { + out.push_str(&format!(" {}\n", change)); + } + } + + if !report.execution_diffs.is_empty() { + out.push('\n'); + out.push_str(&format!( + "Execution Diffs ({}):\n", + report.execution_diffs.len() + )); + for diff in &report.execution_diffs { + let match_str = if diff.outputs_match { + "MATCH" + } else { + "MISMATCH" + }; + out.push_str(&format!( + " {} args={} OLD={} NEW={} [{}]\n", + diff.function, diff.args, diff.old_result, diff.new_result, match_str + )); + } + } + + out.push('\n'); + let old_names: Vec<&str> = report + .old_functions + .iter() + .map(|f| f.name.as_str()) + .collect(); + let new_names: Vec<&str> = report + .new_functions + .iter() + .map(|f| f.name.as_str()) + .collect(); + out.push_str(&format!( + "Old Functions ({}): {}\n", + old_names.len(), + old_names.join(", ") + )); + out.push_str(&format!( + "New Functions ({}): {}\n", + new_names.len(), + new_names.join(", ") + )); + + out +} + +/// Parse JSON arguments with validation. +pub fn parse_args(json: &str) -> Result { + let value = serde_json::from_str::(json).map_err(|e| { + DebuggerError::InvalidArguments(format!( + "Failed to parse JSON arguments: {}. Error: {}", + json, e + )) + })?; + + match value { + serde_json::Value::Array(ref arr) => { + tracing::debug!(count = arr.len(), "Parsed array arguments"); + } + serde_json::Value::Object(ref obj) => { + tracing::debug!(fields = obj.len(), "Parsed object arguments"); + } + _ => { + tracing::debug!("Parsed single value argument"); + } + } + + Ok(json.to_string()) +} + +/// Parse JSON storage. +pub fn parse_storage(json: &str) -> Result { + serde_json::from_str::(json).map_err(|e| { + DebuggerError::StorageError(format!( + "Failed to parse JSON storage: {}. Error: {}", + json, e + )) + })?; + Ok(json.to_string()) +} + +/// Execute the optimize command. +pub fn optimize(args: OptimizeArgs, _verbosity: Verbosity) -> Result<()> { + print_info(format!( + "Analyzing contract for gas optimization: {:?}", + args.contract + )); + logging::log_loading_contract(&args.contract.to_string_lossy()); + + let wasm_file = crate::utils::wasm::load_wasm(&args.contract) + .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; + let wasm_bytes = wasm_file.bytes; + let wasm_hash = wasm_file.sha256_hash; + + if let Some(expected) = &args.expected_hash { + if expected.to_lowercase() != wasm_hash { + return Err((crate::DebuggerError::ChecksumMismatch( + expected.clone(), + wasm_hash.clone(), + )) + .into()); + } + } + + print_success(format!( + "Contract loaded successfully ({} bytes)", + wasm_bytes.len() + )); + + if _verbosity == Verbosity::Verbose { + print_verbose(format!("SHA-256: {}", wasm_hash)); + if args.expected_hash.is_some() { + print_verbose("Checksum verified βœ“"); + } + } + + logging::log_contract_loaded(wasm_bytes.len()); + + if let Some(snapshot_path) = &args.network_snapshot { + print_info(format!("\nLoading network snapshot: {:?}", snapshot_path)); + logging::log_loading_snapshot(&snapshot_path.to_string_lossy()); + let loader = SnapshotLoader::from_file(snapshot_path)?; + let loaded_snapshot = loader.apply_to_environment()?; + logging::log_display(loaded_snapshot.format_summary(), logging::LogLevel::Info); + } + + let functions_to_analyze = if args.function.is_empty() { + print_warning("No functions specified, analyzing all exported functions..."); + crate::utils::wasm::parse_functions(&wasm_bytes)? + } else { + args.function.clone() + }; + + let mut executor = ContractExecutor::new(wasm_bytes)?; + if let Some(storage_json) = &args.storage { + let storage = parse_storage(storage_json)?; + executor.set_initial_storage(storage)?; + } + + let mut optimizer = crate::profiler::analyzer::GasOptimizer::new(executor); + + print_info(format!( + "\nAnalyzing {} function(s)...", + functions_to_analyze.len() + )); + logging::log_analysis_start("gas optimization"); + + for function_name in &functions_to_analyze { + print_info(format!(" Analyzing function: {}", function_name)); + match optimizer.analyze_function(function_name, args.args.as_deref()) { + Ok(profile) => { + logging::log_display( + format!( + " CPU: {} instructions, Memory: {} bytes, Time: {} ms", + profile.total_cpu, profile.total_memory, profile.wall_time_ms + ), + logging::LogLevel::Info, + ); + print_success(format!( + " CPU: {} instructions, Memory: {} bytes", + profile.total_cpu, profile.total_memory + )); + } + Err(e) => { + print_warning(format!( + " Warning: Failed to analyze function {}: {}", + function_name, e + )); + tracing::warn!(function = function_name, error = %e, "Failed to analyze function"); + } + } + } + logging::log_analysis_complete("gas optimization", functions_to_analyze.len()); + + let contract_path_str = args.contract.to_string_lossy().to_string(); + let report = optimizer.generate_report(&contract_path_str); + // Render in the requested format. JSON exposes structured suggestions, + // per-function hotspots, and metadata; pretty stays markdown. We build a + // dedicated serializable view rather than deriving Serialize across the + // whole profiler graph. + let rendered = match args.format { + crate::cli::args::OutputFormat::Json => { + #[derive(serde::Serialize)] + struct Hotspot<'a> { + function: &'a str, + total_cpu: u64, + total_memory: u64, + } + #[derive(serde::Serialize)] + struct Suggestion<'a> { + category: &'a str, + title: &'a str, + description: &'a str, + estimated_cpu_savings: u64, + estimated_memory_savings: u64, + location: &'a str, + priority: String, + } + #[derive(serde::Serialize)] + struct OptimizeJsonReport<'a> { + contract_path: &'a str, + total_cpu: u64, + total_memory: u64, + potential_cpu_savings: u64, + potential_memory_savings: u64, + suggestions: Vec>, + hotspots: Vec>, + } + + let view = OptimizeJsonReport { + contract_path: &report.contract_path, + total_cpu: report.total_cpu, + total_memory: report.total_memory, + potential_cpu_savings: report.potential_cpu_savings, + potential_memory_savings: report.potential_memory_savings, + suggestions: report + .suggestions + .iter() + .map(|s| Suggestion { + category: &s.category, + title: &s.title, + description: &s.description, + estimated_cpu_savings: s.estimated_cpu_savings, + estimated_memory_savings: s.estimated_memory_savings, + location: &s.location, + priority: s.priority.to_string(), + }) + .collect(), + hotspots: report + .functions + .iter() + .map(|f| Hotspot { + function: &f.name, + total_cpu: f.total_cpu, + total_memory: f.total_memory, + }) + .collect(), + }; + crate::output::to_json_string(&view).map_err(|e| { + DebuggerError::FileError(format!( + "Failed to serialize optimization report as JSON: {}", + e + )) + })? + } + crate::cli::args::OutputFormat::Pretty => optimizer.generate_markdown_report(&report), + }; + + if let Some(output_path) = &args.output { + fs::write(output_path, &rendered).map_err(|e| { + DebuggerError::FileError(format!( + "Failed to write report to {:?}: {}", + output_path, e + )) + })?; + print_success(format!( + "\nOptimization report written to: {:?}", + output_path + )); + logging::log_optimization_report(&output_path.to_string_lossy()); + } else { + logging::log_display(&rendered, logging::LogLevel::Info); + } + + Ok(()) +} + +/// βœ… Execute the profile command (hotspots + suggestions) +pub fn profile(args: ProfileArgs) -> Result<()> { + logging::log_display( + format!("Profiling contract execution: {:?}", args.contract), + logging::LogLevel::Info, + ); + + let wasm_file = crate::utils::wasm::load_wasm(&args.contract) + .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; + let wasm_bytes = wasm_file.bytes; + let wasm_hash = wasm_file.sha256_hash; + + if let Some(expected) = &args.expected_hash { + if expected.to_lowercase() != wasm_hash { + return Err((crate::DebuggerError::ChecksumMismatch( + expected.clone(), + wasm_hash.clone(), + )) + .into()); + } + } + + logging::log_display( + format!("Contract loaded successfully ({} bytes)", wasm_bytes.len()), + logging::LogLevel::Info, + ); + + // Parse args (optional) + let parsed_args = if let Some(args_json) = &args.args { + Some(parse_args(args_json)?) + } else { + None + }; + + // Create executor + let mut executor = ContractExecutor::new(wasm_bytes)?; + + // Initial storage (optional) + if let Some(storage_json) = &args.storage { + let storage = parse_storage(storage_json)?; + executor.set_initial_storage(storage)?; + } + + // Analyze exactly one function (this command focuses on execution hotspots) + let mut optimizer = crate::profiler::analyzer::GasOptimizer::new(executor); + + logging::log_display( + format!("\nRunning function: {}", args.function), + logging::LogLevel::Info, + ); + if let Some(ref a) = parsed_args { + logging::log_display(format!("Args: {}", a), logging::LogLevel::Info); + } + + let _profile = optimizer.analyze_function(&args.function, parsed_args.as_deref())?; + + let contract_path_str = args.contract.to_string_lossy().to_string(); + let report = optimizer.generate_report(&contract_path_str); + + // Format output based on export_format + let output_content = match args.export_format { + crate::cli::args::ProfileExportFormat::FoldedStack => { + // Export in folded stack format for external tools (issue #502) + optimizer.to_folded_stack_format(&report) + } + crate::cli::args::ProfileExportFormat::Json => { + // Export as JSON with basic metrics + let func_names: Vec = report.functions.iter().map(|f| f.name.clone()).collect(); + crate::output::to_json_string(&serde_json::json!({ + "contract": contract_path_str, + "functions": func_names, + "total_cpu": report.total_cpu, + "total_memory": report.total_memory, + "potential_cpu_savings": report.potential_cpu_savings, + "potential_memory_savings": report.potential_memory_savings, + })) + .unwrap_or_else(|_| "{}".to_string()) + } + crate::cli::args::ProfileExportFormat::Report => { + // Default markdown report + let hotspots = report.format_hotspots(); + let markdown = optimizer.generate_markdown_report(&report); + logging::log_display(format!("\n{}", hotspots), logging::LogLevel::Info); + markdown + } + }; + + if let Some(output_path) = &args.output { + fs::write(output_path, &output_content).map_err(|e| { + DebuggerError::FileError(format!( + "Failed to write report to {:?}: {}", + output_path, e + )) + })?; + logging::log_display( + format!("\nProfile report written to: {:?}", output_path), + logging::LogLevel::Info, + ); + } else if !matches!( + args.export_format, + crate::cli::args::ProfileExportFormat::Report + ) { + // Only print output_content for non-Report formats if no file specified + logging::log_display(format!("\n{}", output_content), logging::LogLevel::Info); + } + + Ok(()) +} + +/// Execute the compare command. +pub fn compare(args: CompareArgs) -> Result<()> { + print_info(format!("Loading trace A: {:?}", args.trace_a)); + let trace_a = crate::compare::ExecutionTrace::from_file(&args.trace_a)?; + + print_info(format!("Loading trace B: {:?}", args.trace_b)); + let trace_b = crate::compare::ExecutionTrace::from_file(&args.trace_b)?; + + print_info("Comparing traces..."); + let filters = crate::compare::engine::CompareFilters::new( + args.ignore_path.clone(), + args.ignore_field.clone(), + )?; + let report = crate::compare::CompareEngine::compare_with_filters(&trace_a, &trace_b, &filters); + let rendered = match args.format { + OutputFormat::Json => { + let envelope = crate::output::VersionedOutput::success("compare", &report); + serde_json::to_string_pretty(&envelope).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize comparison report: {}", e)) + })? + } + OutputFormat::Pretty => crate::compare::CompareEngine::render_report(&report), + }; + + if let Some(output_path) = &args.output { + fs::write(output_path, &rendered).map_err(|e| { + DebuggerError::FileError(format!( + "Failed to write report to {:?}: {}", + output_path, e + )) + })?; + print_success(format!("Comparison report written to: {:?}", output_path)); + } else { + println!("{}", rendered); + } + + Ok(()) +} + +/// Execute the replay command. +/// Execute the replay command. +pub fn replay(args: ReplayArgs, verbosity: Verbosity) -> Result<()> { + let is_json = args.format == crate::cli::args::OutputFormat::Json; + if !is_json { + print_info(format!("Loading trace file: {:?}", args.trace_file)); + } + // Fail fast on malformed or unsupported-schema-version traces (#1288). + let raw_trace: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&args.trace_file).map_err(|e| { + DebuggerError::FileError(format!( + "Failed to read trace file {:?}: {}", + args.trace_file, e + )) + })?) + .map_err(|e| { + DebuggerError::FileError(format!( + "Failed to parse trace file {:?} as JSON: {}", + args.trace_file, e + )) + })?; + crate::compare::trace::validate_trace_schema(&raw_trace)?; + let original_trace = crate::compare::ExecutionTrace::from_file(&args.trace_file)?; + + // Determine which contract to use + let contract_path = if let Some(path) = &args.contract { + path.clone() + } else if let Some(contract_str) = &original_trace.contract { + std::path::PathBuf::from(contract_str) + } else { + return Err(DebuggerError::ExecutionError( + "No contract path specified and trace file does not contain contract path".to_string(), + ) + .into()); + }; + + if !is_json { + print_info(format!("Loading contract: {:?}", contract_path)); + } + let wasm_bytes = fs::read(&contract_path).map_err(|e| { + DebuggerError::WasmLoadError(format!( + "Failed to read WASM file at {:?}: {}", + contract_path, e + )) + })?; + + if !is_json { + print_success(format!( + "Contract loaded successfully ({} bytes)", + wasm_bytes.len() + )); + } + + // Extract function and args from trace + let function = original_trace.function.as_ref().ok_or_else(|| { + DebuggerError::ExecutionError("Trace file does not contain function name".to_string()) + })?; + + let args_str = original_trace.args.as_deref(); + + // Determine how many steps to replay + let replay_steps = args.replay_until.unwrap_or(usize::MAX); + let is_partial_replay = args.replay_until.is_some(); + + if !is_json { + if is_partial_replay { + print_info(format!("Replaying up to step {}", replay_steps)); + } else { + print_info("Replaying full execution"); + } + + print_info(format!("Function: {}", function)); + if let Some(a) = args_str { + print_info(format!("Arguments: {}", a)); + } + } + + // Set up initial storage from trace + let initial_storage = if !original_trace.storage.is_empty() { + let storage_json = serde_json::to_string(&original_trace.storage).map_err(|e| { + DebuggerError::StorageError(format!("Failed to serialize trace storage: {}", e)) + })?; + Some(storage_json) + } else { + None + }; + + // Execute the contract + if !is_json { + print_info("\n--- Replaying Execution ---\n"); + } + let mut executor = ContractExecutor::new(wasm_bytes)?; + + if let Some(storage) = initial_storage { + executor.set_initial_storage(storage)?; + } + + let mut engine = DebuggerEngine::new(executor, vec![]); + + logging::log_execution_start(function, args_str); + let replayed_result = engine.execute(function, args_str)?; + + if !is_json { + print_success("\n--- Replay Complete ---\n"); + print_success(format!("Replayed Result: {:?}", replayed_result)); + } + logging::log_execution_complete(&replayed_result); + + // Build execution trace from the replay + let storage_after = engine.executor().get_storage_snapshot()?; + let trace_events = engine.executor().get_events().unwrap_or_default(); + let budget = crate::inspector::budget::BudgetInspector::get_cpu_usage(engine.executor().host()); + + let replayed_trace = build_execution_trace( + function, + &contract_path.to_string_lossy(), + args_str.map(|s| s.to_string()), + &storage_after, + &replayed_result, + budget, + engine.executor(), + &trace_events, + replay_steps, + ); + + // Truncate original_trace's call_sequence if needed to match replay_until + let mut truncated_original = original_trace.clone(); + if truncated_original.call_sequence.len() > replay_steps { + truncated_original.call_sequence.truncate(replay_steps); + } + + // Compare results + if !is_json { + print_info("\n--- Comparison ---"); + } + let report = crate::compare::CompareEngine::compare(&truncated_original, &replayed_trace); + + if is_json { + let envelope = crate::output::VersionedOutput::success("replay", &report); + let rendered = crate::output::to_json_string(&envelope).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize replay report: {}", e)) + })?; + + if let Some(output_path) = &args.output { + std::fs::write(output_path, &rendered).map_err(|e| { + DebuggerError::FileError(format!( + "Failed to write report to {:?}: {}", + output_path, e + )) + })?; + print_success(format!("\nReplay report written to: {:?}", output_path)); + } else { + println!("{}", rendered); + } + } else { + let rendered = crate::compare::CompareEngine::render_report(&report); + + if let Some(output_path) = &args.output { + std::fs::write(output_path, &rendered).map_err(|e| { + DebuggerError::FileError(format!( + "Failed to write report to {:?}: {}", + output_path, e + )) + })?; + print_success(format!("\nReplay report written to: {:?}", output_path)); + } else { + logging::log_display(rendered, logging::LogLevel::Info); + } + } + + if !is_json && verbosity == Verbosity::Verbose { + print_verbose("\n--- Call Sequence (Original) ---"); + for (i, call) in original_trace.call_sequence.iter().enumerate() { + let indent = " ".repeat(call.depth as usize); + if let Some(args) = &call.args { + print_verbose(format!("{}{}. {} ({})", indent, i, call.function, args)); + } else { + print_verbose(format!("{}{}. {}", indent, i, call.function)); + } + + if is_partial_replay && i >= replay_steps { + print_verbose(format!("{}... (stopped at step {})", indent, replay_steps)); + break; + } + } + } + + Ok(()) +} + +/// Start debug server for remote connections +pub fn server(args: ServerArgs) -> Result<()> { + print_info(format!( + "Starting remote debug server on {}:{}", + args.host, args.port + )); + if args.token.is_some() { + print_info("Token authentication enabled"); + } else { + print_info("Token authentication disabled"); + } + // #1262: apply the token-strength policy. Warn by default; reject startup when + // --require-strong-token is set and the token is too short. + match evaluate_token_policy(args.token.as_deref(), args.require_strong_token) { + TokenPolicy::Ok => {} + TokenPolicy::Warn(msg) => print_warning(&msg), + TokenPolicy::Reject(msg) => { + return Err(crate::DebuggerError::InvalidArguments(msg).into()); + } + } + if args.tls_cert.is_some() || args.tls_key.is_some() { + print_info("TLS enabled"); + } else if args.token.is_some() { + print_warning( + "Token authentication is enabled without TLS. Assume traffic is plaintext unless you \ + are using a trusted private network or external TLS termination.", + ); + } + + let server = crate::server::DebugServer::new( + args.host.clone(), + args.token.clone(), + args.tls_cert.as_deref(), + args.tls_key.as_deref(), + args.repeat, + args.storage_filter, + args.show_events, + args.event_filter, + args.mock, + )?; + + tokio::runtime::Runtime::new() + .map_err(|e: std::io::Error| miette::miette!(e)) + .and_then(|rt| rt.block_on(server.run(args.port))) +} + +/// Connect to remote debug server +pub fn remote(args: RemoteArgs, _verbosity: Verbosity) -> Result<()> { + let is_json = args.format == crate::cli::args::OutputFormat::Json; + + if !is_json { + print_info(format!("Connecting to remote debugger at {}", args.remote)); + } + + // Build per-request timeouts, falling back to the general --timeout-ms for + // the specialised classes when the user did not set them explicitly. + let default_ms = args.timeout_ms; + let timeouts = crate::client::RemoteClientConfig::build_timeouts( + default_ms, + args.inspect_timeout_ms, + args.storage_timeout_ms, + ); + + let config = crate::client::RemoteClientConfig { + connect_timeout: std::time::Duration::from_millis(args.connect_timeout_ms), + timeouts, + retry: crate::client::RetryPolicy { + max_attempts: args.retry_attempts, + base_delay: std::time::Duration::from_millis(args.retry_base_delay_ms), + max_delay: std::time::Duration::from_millis(args.retry_max_delay_ms), + }, + tls_cert: args.tls_cert.clone(), + tls_key: args.tls_key.clone(), + tls_ca: args.tls_ca.clone(), + session_label: args.session_label.clone(), + ..Default::default() + }; + + let mut client = + crate::client::RemoteClient::connect_with_config(&args.remote, args.token.clone(), config).map_err(|e| { + // Enrich connect-specific errors with a hint about --connect-timeout-ms so + // the user knows which knob to turn without having to read the docs first. + let msg = e.to_string(); + if msg.contains("Request timed out") || msg.contains("timed out") || msg.contains("Connection refused") || msg.contains("Network/transport error") { + miette::miette!("{}\n\nHint: use --connect-timeout-ms (current: {}ms) to extend the initial TCP connect window, or set SOROBAN_DEBUG_CONNECT_TIMEOUT_MS. See docs/remote-troubleshooting.md for the full diagnostic matrix.", + msg, + args.connect_timeout_ms) + } else { + miette::miette!("{}", msg) + } + })?; + + if !is_json { + if let Some(info) = client.session_info() { + print_info(format!( + "Remote session: {} (created {}, label={})", + info.session_id, + info.created_at, + info.label.as_deref().unwrap_or("") + )); + } + } + + if let Some(contract) = &args.contract { + if !is_json { + print_info(format!("Loading contract: {:?}", contract)); + } + let size = client.load_contract(&contract.to_string_lossy())?; + if !is_json { + print_success(format!("Contract loaded: {} bytes", size)); + } + } + + if let Some(action) = &args.action { + return match action { + RemoteAction::Inspect => { + let (function, step_count, paused, call_stack, pause_reason) = client.inspect()?; + if is_json { + #[derive(serde::Serialize)] + struct InspectJsonResult { + function: Option, + step_count: u64, + paused: bool, + pause_reason: Option, + call_stack: Vec, + } + let result = InspectJsonResult { + function, + step_count, + paused, + pause_reason, + call_stack, + }; + let envelope = crate::output::VersionedOutput::success("remote/inspect", result); + println!("{}", crate::output::to_json_string(&envelope).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize inspect JSON output: {}", e)) + })?); + } else { + println!("Function: {}", function.as_deref().unwrap_or("")); + println!("Step count: {}", step_count); + println!("Paused: {}", paused); + if let Some(reason) = pause_reason { + println!("Pause reason: {}", reason); + } + if !call_stack.is_empty() { + println!("Call stack:"); + for frame in &call_stack { + println!(" {}", frame); + } + } + } + Ok(()) + } + RemoteAction::Storage => { + let storage_json = client.get_storage()?; + if is_json { + let parsed: serde_json::Value = serde_json::from_str(&storage_json).unwrap_or(serde_json::Value::Null); + let envelope = crate::output::VersionedOutput::success("remote/storage", parsed); + println!("{}", crate::output::to_json_string(&envelope).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize storage JSON output: {}", e)) + })?); + } else { + println!("{}", storage_json); + } + Ok(()) + } + RemoteAction::Evaluate(eval_args) => { + let (result, result_type) = + client.evaluate(&eval_args.expression, eval_args.frame_id)?; + if is_json { + #[derive(serde::Serialize)] + struct EvaluateJsonResult { + result: String, + result_type: Option, + } + let envelope = crate::output::VersionedOutput::success("remote/evaluate", EvaluateJsonResult { result, result_type }); + println!("{}", crate::output::to_json_string(&envelope).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize evaluate JSON output: {}", e)) + })?); + } else { + if let Some(rtype) = &result_type { + println!("[{}] {}", rtype, result); + } else { + println!("{}", result); + } + } + Ok(()) + } + }; + } + + if let Some(function) = &args.function { + if is_json { + let result = client.execute(function, args.args.as_deref())?; + #[derive(serde::Serialize)] + struct ExecuteJsonResult { + result: String, + } + let envelope = crate::output::VersionedOutput::success("remote/execute", ExecuteJsonResult { result }); + println!("{}", crate::output::to_json_string(&envelope).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize execute JSON output: {}", e)) + })?); + } else { + print_info(format!("Executing function: {}", function)); + let result = client.execute(function, args.args.as_deref())?; + print_success(format!("Result: {}", result)); + } + return Ok(()); + } + + client.ping()?; + if is_json { + #[derive(serde::Serialize)] + struct PingJsonResult { + reachable: bool, + message: String, + } + let envelope = crate::output::VersionedOutput::success( + "remote/ping", + PingJsonResult { + reachable: true, + message: "Remote debugger is reachable".to_string(), + }, + ); + println!("{}", crate::output::to_json_string(&envelope).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize ping JSON output: {}", e)) + })?); + } else { + print_success("Remote debugger is reachable"); + } + Ok(()) +} +/// Launch interactive debugger UI +pub fn interactive(args: InteractiveArgs, _verbosity: Verbosity) -> Result<()> { + print_info(format!("Loading contract: {:?}", args.contract)); + logging::log_loading_contract(&args.contract.to_string_lossy()); + + let wasm_file = crate::utils::wasm::load_wasm(&args.contract) + .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; + let wasm_bytes = wasm_file.bytes; + let wasm_hash = wasm_file.sha256_hash; + + if let Some(expected) = &args.expected_hash { + if expected.to_lowercase() != wasm_hash { + return Err((crate::DebuggerError::ChecksumMismatch( + expected.clone(), + wasm_hash.clone(), + )) + .into()); + } + } + + print_success(format!( + "Contract loaded successfully ({} bytes)", + wasm_bytes.len() + )); + + if let Some(snapshot_path) = &args.network_snapshot { + print_info(format!("Loading network snapshot: {:?}", snapshot_path)); + logging::log_loading_snapshot(&snapshot_path.to_string_lossy()); + let loader = SnapshotLoader::from_file(snapshot_path)?; + let loaded_snapshot = loader.apply_to_environment()?; + logging::log_display(loaded_snapshot.format_summary(), logging::LogLevel::Info); + } + + let parsed_args = if let Some(args_json) = &args.args { + Some(parse_args(args_json)?) + } else { + None + }; + + let mut initial_storage = if let Some(storage_json) = &args.storage { + Some(parse_storage(storage_json)?) + } else { + None + }; + + if let Some(import_path) = &args.import_storage { + print_info(format!("Importing storage from: {:?}", import_path)); + let imported = crate::inspector::storage::StorageState::import_from_file(import_path)?; + print_success(format!("Imported {} storage entries", imported.len())); + initial_storage = Some(serde_json::to_string(&imported).map_err(|e| { + DebuggerError::StorageError(format!("Failed to serialize imported storage: {}", e)) + })?); + } + + let mut executor = ContractExecutor::new(wasm_bytes.clone())?; + executor.set_timeout(args.timeout); + + if let Some(storage) = initial_storage { + executor.set_initial_storage(storage)?; + } + if !args.mock.is_empty() { + executor.set_mock_specs(&args.mock)?; + } + + let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone()); + + if args.instruction_debug { + print_info("Enabling instruction-level debugging..."); + engine.enable_instruction_debug(&wasm_bytes)?; + + if args.step_instructions { + let step_mode = parse_step_mode(&args.step_mode)?; + engine.start_instruction_stepping(step_mode)?; + } + } + + print_info("Starting interactive session (type 'help' for commands)"); + let mut ui = DebuggerUI::new(engine)?; + ui.queue_execution(args.function.clone(), parsed_args); + ui.run() +} + +/// Launch TUI debugger +pub fn tui(args: TuiArgs, _verbosity: Verbosity) -> Result<()> { + print_info(format!("Loading contract: {:?}", args.contract)); + let wasm_file = crate::utils::wasm::load_wasm(&args.contract) + .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; + let wasm_bytes = wasm_file.bytes; + + print_success(format!( + "Contract loaded successfully ({} bytes)", + wasm_bytes.len() + )); + + if let Some(snapshot_path) = &args.network_snapshot { + print_info(format!("Loading network snapshot: {:?}", snapshot_path)); + logging::log_loading_snapshot(&snapshot_path.to_string_lossy()); + let loader = SnapshotLoader::from_file(snapshot_path)?; + let loaded_snapshot = loader.apply_to_environment()?; + logging::log_display(loaded_snapshot.format_summary(), logging::LogLevel::Info); + } + + let parsed_args = if let Some(args_json) = &args.args { + Some(parse_args(args_json)?) + } else { + None + }; + + let initial_storage = if let Some(storage_json) = &args.storage { + Some(parse_storage(storage_json)?) + } else { + None + }; + + let mut executor = ContractExecutor::new(wasm_bytes.clone())?; + + if let Some(storage) = initial_storage { + executor.set_initial_storage(storage)?; + } + + let mut engine = DebuggerEngine::new(executor, args.breakpoint.clone()); + engine.stage_execution(&args.function, parsed_args.as_deref()); + + run_dashboard(engine, &args.function) +} + +/// Inspect a WASM contract +pub fn inspect(args: InspectArgs, _verbosity: Verbosity) -> Result<()> { + let wasm_file = crate::utils::wasm::load_wasm(&args.contract) + .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; + if let Some(expected) = &args.expected_hash { + if !wasm_file.sha256_hash.eq_ignore_ascii_case(expected) { + return Err(crate::DebuggerError::ChecksumMismatch( + expected.clone(), + wasm_file.sha256_hash.clone(), + ) + .into()); + } + } + + let bytes = wasm_file.bytes; + + if args.source_map_diagnostics { + return inspect_source_map_diagnostics(&args, &bytes); + } + + let info = crate::utils::wasm::get_module_info(&bytes)?; + let artifact_metadata = crate::utils::wasm::extract_wasm_artifact_metadata(&bytes)?; + if args.format == OutputFormat::Json { + let exported_functions = if args.functions { + Some(crate::utils::wasm::parse_function_signatures(&bytes)?) + } else { + None + }; + let result = serde_json::json!({ + "contract": args.contract.display().to_string(), + "size_bytes": info.total_size, + "types": info.type_count, + "functions": info.function_count, + "exports": info.export_count, + "exported_functions": exported_functions, + "artifact_metadata": artifact_metadata, + }); + let envelope = crate::output::VersionedOutput::success("inspect", result); + println!( + "{}", + crate::output::to_json_string(&envelope).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize inspect JSON output: {}", e)) + })? + ); + return Ok(()); + } + + println!("Contract: {:?}", args.contract); + println!("Size: {} bytes", info.total_size); + println!("Types: {}", info.type_count); + println!("Functions: {}", info.function_count); + println!("Exports: {}", info.export_count); + println!("Artifact metadata:"); + println!( + " Build profile hint: {}", + artifact_metadata.build_profile_hint + ); + println!( + " Optimization hint: {}", + artifact_metadata.optimization_hint + ); + println!( + " Name section: {}", + if artifact_metadata.name_section_present { + "present" + } else { + "absent" + } + ); + println!( + " DWARF debug sections: {}", + if artifact_metadata.has_debug_sections { + if artifact_metadata.debug_sections.is_empty() { + "present".to_string() + } else { + format!( + "present ({}, {} bytes)", + artifact_metadata.debug_sections.join(", "), + artifact_metadata.debug_section_bytes + ) + } + } else { + "absent".to_string() + } + ); + if let Some(module_name) = &artifact_metadata.module_name { + println!(" Module name: {}", module_name); + } + if !artifact_metadata.package_hints.is_empty() { + println!(" Package hints:"); + for hint in &artifact_metadata.package_hints { + println!(" - {}", hint); + } + } + if !artifact_metadata.producers.is_empty() { + println!(" Producers:"); + for field in &artifact_metadata.producers { + let values = field + .values + .iter() + .map(|value| { + if value.version.is_empty() { + value.name.clone() + } else { + format!("{} {}", value.name, value.version) + } + }) + .collect::>() + .join(", "); + println!(" {}: {}", field.name, values); + } + } + if !artifact_metadata.heuristic_notes.is_empty() { + println!(" Notes:"); + for note in &artifact_metadata.heuristic_notes { + println!(" - {}", note); + } + } + if args.functions { + let sigs = crate::utils::wasm::parse_function_signatures(&bytes)?; + println!("Exported functions:"); + for sig in &sigs { + let params: Vec = sig + .params + .iter() + .map(|p| format!("{}: {}", p.name, p.type_name)) + .collect(); + let ret = sig.return_type.as_deref().unwrap_or("()"); + println!(" {}({}) -> {}", sig.name, params.join(", "), ret); + } + } + Ok(()) +} + +fn inspect_source_map_diagnostics(args: &InspectArgs, wasm_bytes: &[u8]) -> Result<()> { + let report = + crate::debugger::source_map::SourceMap::inspect_wasm(wasm_bytes, args.source_map_limit)?; + + match args.format { + OutputFormat::Json => { + let output = SourceMapDiagnosticsCommandOutput { + contract: args.contract.display().to_string(), + source_map: report, + }; + let pretty = crate::output::to_json_string(&output).map_err(|e| { + DebuggerError::ExecutionError(format!( + "Failed to serialize source-map diagnostics JSON output: {e}" + )) + })?; + println!("{pretty}"); + } + OutputFormat::Pretty => { + println!("Source Map Diagnostics"); + println!("Contract: {}", args.contract.display()); + println!("Resolved mappings: {}", report.mappings_count); + println!("Fallback mode: {}", report.fallback_mode); + println!("Fallback behavior: {}", report.fallback_message); + + println!("\nDWARF sections:"); + for section in &report.sections { + let status = if section.present { + "present" + } else { + "missing" + }; + println!( + " {}: {} ({} bytes)", + section.name, status, section.size_bytes + ); + } + + if report.preview.is_empty() { + println!("\nResolved mappings preview: none"); + } else { + println!("\nResolved mappings preview:"); + for mapping in &report.preview { + let column = mapping + .location + .column + .map(|column| format!(":{}", column)) + .unwrap_or_default(); + println!( + " 0x{offset:08x} -> {file}:{line}{column}", + offset = mapping.offset, + file = mapping.location.file.display(), + line = mapping.location.line, + column = column + ); + } + } + + if report.diagnostics.is_empty() { + println!("\nDiagnostics: none"); + } else { + println!("\nDiagnostics:"); + for diagnostic in &report.diagnostics { + println!(" - {}", diagnostic.message); + } + } + } + } + + Ok(()) +} + +/// Run symbolic execution analysis +pub fn symbolic(args: SymbolicArgs, _verbosity: Verbosity) -> Result<()> { + print_info(format!("Loading contract: {:?}", args.contract)); + let wasm_file = crate::utils::wasm::load_wasm(&args.contract) + .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; + + let analyzer = SymbolicAnalyzer::new(); + let config = symbolic_config_from_args(&args)?; + let report = analyzer.analyze_with_config(&wasm_file.bytes, &args.function, &config)?; + + match args.format { + OutputFormat::Pretty => { + println!("{}", render_symbolic_report(&report)); + } + OutputFormat::Json => { + let envelope = crate::output::VersionedOutput::success("symbolic", &report); + println!( + "{}", + crate::output::to_json_string(&envelope).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize symbolic report: {}", e)) + })? + ); + } + } + + if let Some(output_path) = &args.output { + let scenario_toml = analyzer.generate_scenario_toml(&report); + fs::write(output_path, scenario_toml).map_err(|e| { + DebuggerError::FileError(format!( + "Failed to write symbolic scenario to {:?}: {}", + output_path, e + )) + })?; + print_success(format!("Scenario TOML written to: {:?}", output_path)); + } + + if let Some(bundle_path) = &args.export_replay_bundle { + let bundle = build_replay_bundle( + &config, + &report, + wasm_file.sha256_hash.clone(), + Some(args.contract.to_string_lossy().to_string()), + ); + let serialized = crate::output::to_json_string(&bundle).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize replay bundle to JSON: {}", e)) + })?; + fs::write(bundle_path, serialized).map_err(|e| { + DebuggerError::FileError(format!( + "Failed to write replay bundle to {:?}: {}", + bundle_path, e + )) + })?; + print_success(format!("Replay bundle written to: {:?}", bundle_path)); + } + + Ok(()) +} + +/// Analyze a contract +pub fn analyze(args: AnalyzeArgs, _verbosity: Verbosity) -> Result<()> { + print_info(format!("Loading contract: {:?}", args.contract)); + let wasm_file = crate::utils::wasm::load_wasm(&args.contract) + .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; + + let mut dynamic_analysis = None; + let mut warnings = Vec::new(); + let mut executor = None; + let mut trace_entries = None; + + if let Some(function) = &args.function { + let mut dynamic_executor = ContractExecutor::new(wasm_file.bytes.clone())?; + dynamic_executor.enable_mock_all_auths(); + dynamic_executor.set_timeout(args.timeout); + + if let Some(storage_json) = &args.storage { + dynamic_executor.set_initial_storage(parse_storage(storage_json)?)?; + } + + let parsed_args = if let Some(args_json) = &args.args { + Some(parse_args(args_json)?) + } else { + None + }; + + match dynamic_executor.execute(function, parsed_args.as_deref()) { + Ok(result) => { + let trace = dynamic_executor.get_dynamic_trace().unwrap_or_default(); + + dynamic_analysis = Some(DynamicAnalysisMetadata { + function: function.clone(), + args: parsed_args.clone(), + result: Some(result), + trace_entries: trace.len(), + }); + trace_entries = Some(trace); + executor = Some(dynamic_executor); + } + Err(err) => { + warnings.push(format!( + "Dynamic analysis for function '{}' failed: {}", + function, err + )); + } + } + } + + let mut analyzer = SecurityAnalyzer::new(); + let config = crate::config::Config::load_or_default(); + if let Some(supp_path) = config.output.suppressions_file { + if std::path::Path::new(&supp_path).exists() { + analyzer = analyzer.load_suppressions_from_file(&supp_path)?; + } + } + // Get registered rule IDs for validation + let registered_rules: Vec = analyzer + .get_rules() + .iter() + .map(|rule| rule.id().to_string()) + .collect(); + + // Validate rule IDs + validate_rule_ids(&args.enable_rule, &args.disable_rule, ®istered_rules)?; + + let filter = crate::analyzer::security::AnalyzerFilter { + enable_rules: args.enable_rule.clone(), + disable_rules: args.disable_rule.clone(), + min_severity: convert_min_severity(args.min_severity), + }; + let contract_path = args.contract.to_string_lossy().to_string(); + let report = analyzer.analyze( + &wasm_file.bytes, + executor.as_ref(), + trace_entries.as_deref(), + &filter, + &contract_path, + )?; + let output = AnalyzeCommandOutput { + findings: report.findings, + rules: report.rules.into_iter().collect(), + dynamic_analysis, + warnings, + suppressed_count: report.metadata.suppressed_count, + }; + + match args.format.to_lowercase().as_str() { + "text" => println!("{}", render_security_report(&output)), + "json" => { + let envelope = crate::output::VersionedOutput::success("analyze", &output); + println!( + "{}", + crate::output::to_json_string(&envelope).map_err(|e| { + DebuggerError::FileError(format!("Failed to serialize analysis output: {}", e)) + })? + ); + } + other => { + return Err(DebuggerError::InvalidArguments(format!( + "Unsupported --format '{}'. Use 'text' or 'json'.", + other + )) + .into()); + } + } + + Ok(()) +} + +#[derive(Debug, Clone, serde::Serialize)] +struct DoctorCheck { + ok: bool, + message: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct RemoteDoctorReport { + address: String, + connect: DoctorCheck, + handshake: Option, + ping: Option, + auth: Option, + selected_protocol: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct DoctorReport { + binary: serde_json::Value, + config: serde_json::Value, + history: serde_json::Value, + plugins: serde_json::Value, + protocol: serde_json::Value, + remote: Option, + vscode_extension: serde_json::Value, +} + +fn check_ok(message: impl Into) -> DoctorCheck { + DoctorCheck { + ok: true, + message: message.into(), + } +} + +fn check_err(message: impl Into) -> DoctorCheck { + DoctorCheck { + ok: false, + message: message.into(), + } +} + +fn env_truthy(name: &str) -> bool { + std::env::var(name) + .ok() + .is_some_and(|v| matches!(v.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) +} + +fn read_repo_vscode_extension_version(manifest_path: Option<&PathBuf>) -> Option { + let path = manifest_path.cloned().unwrap_or_else(|| { + PathBuf::from("extensions") + .join("vscode") + .join("package.json") + }); + let text = std::fs::read_to_string(path).ok()?; + let v: serde_json::Value = serde_json::from_str(&text).ok()?; + v.get("version")?.as_str().map(|s| s.to_string()) +} + +fn compute_default_history_path() -> Result { + if let Ok(path) = std::env::var("SOROBAN_DEBUG_HISTORY_FILE") { + return Ok(PathBuf::from(path)); + } + + let home_dir = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map_err(|_| DebuggerError::FileError("Could not determine home directory".to_string()))?; + Ok(PathBuf::from(home_dir) + .join(".soroban-debug") + .join("history.json")) +} + +fn history_file_status(path: &PathBuf) -> serde_json::Value { + let exists = path.exists(); + let metadata = std::fs::metadata(path).ok(); + let size = metadata.as_ref().map(|m| m.len()); + + let readable = std::fs::File::open(path).is_ok(); + let writable = std::fs::OpenOptions::new().append(true).open(path).is_ok(); + + serde_json::json!({ + "path": path, + "exists": exists, + "size_bytes": size, + "readable": readable || !exists, + "writable": writable || !exists, + }) +} + +fn config_status() -> serde_json::Value { + let path = std::path::Path::new(crate::config::DEFAULT_CONFIG_FILE).to_path_buf(); + let exists = path.exists(); + let load = crate::config::Config::load(); + let parse_ok = load.is_ok() || !exists; + let error = load.err().map(|e| e.to_string()); + + serde_json::json!({ + "path": path, + "exists": exists, + "parse_ok": parse_ok, + "error": error, + }) +} + +fn plugin_status() -> serde_json::Value { + let disabled = env_truthy("SOROBAN_DEBUG_NO_PLUGINS"); + let plugin_dir = crate::plugin::PluginLoader::default_plugin_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "".to_string()); + + let discovered = crate::plugin::PluginLoader::default_plugin_dir() + .map(|dir| crate::plugin::PluginLoader::new(dir).discover_plugins()) + .unwrap_or_default(); + + let registry = crate::plugin::registry::init_global_plugin_registry(); + let stats = registry.read().map(|r| r.statistics()).unwrap_or_default(); + + serde_json::json!({ + "disabled_via_env": disabled, + "plugin_dir": plugin_dir, + "discovered_manifests": discovered.len(), + "loaded_plugins": stats.total, + "provides_commands": stats.provides_commands, + "provides_formatters": stats.provides_formatters, + "supports_hot_reload": stats.supports_hot_reload, + }) +} + +fn protocol_status() -> serde_json::Value { + serde_json::json!({ + "min": crate::server::protocol::PROTOCOL_MIN_VERSION, + "max": crate::server::protocol::PROTOCOL_MAX_VERSION, + "current": crate::server::protocol::PROTOCOL_VERSION, + }) +} + +fn binary_status() -> serde_json::Value { + serde_json::json!({ + "name": env!("CARGO_PKG_NAME"), + "version": env!("CARGO_PKG_VERSION"), + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + }) +} + +fn vscode_extension_status(vscode_manifest: Option<&PathBuf>) -> serde_json::Value { + let version = read_repo_vscode_extension_version(vscode_manifest); + serde_json::json!({ + "version_hint": version, + "wire_protocol_expected_min": crate::server::protocol::PROTOCOL_MIN_VERSION, + "wire_protocol_expected_max": crate::server::protocol::PROTOCOL_MAX_VERSION, + }) +} + +/// Run a scenario +pub fn scenario(args: ScenarioArgs, _verbosity: Verbosity) -> Result<()> { + crate::scenario::run_scenario(args, _verbosity) +} + +/// Launch the REPL +pub async fn repl(args: ReplArgs) -> Result<()> { + print_info(format!("Loading contract: {:?}", args.contract)); + let wasm_file = crate::utils::wasm::load_wasm(&args.contract) + .with_context(|| format!("Failed to read WASM file: {:?}", args.contract))?; + crate::utils::wasm::verify_wasm_hash(&wasm_file.sha256_hash, args.expected_hash.as_ref())?; + + if args.expected_hash.is_some() { + print_verbose("Checksum verified βœ“"); + } + + crate::repl::start_repl(ReplConfig { + contract_path: args.contract, + network_snapshot: args.network_snapshot, + storage: args.storage, + watch_keys: args.watch_keys, + }) + .await +} + +/// Show budget trend chart +pub fn show_budget_trend( + contract: Option<&str>, + function: Option<&str>, + regression: crate::history::RegressionConfig, +) -> Result<()> { + let manager = HistoryManager::new()?; + let mut records = manager.filter_history(contract, function)?; + + crate::history::sort_records_by_date(&mut records); + + if records.is_empty() { + if !Formatter::is_quiet() { + println!("Budget Trend"); + println!( + "Filters: contract={} function={}", + contract.unwrap_or("*"), + function.unwrap_or("*") + ); + println!("No run history found yet."); + println!("Tip: run `soroban-debug run ...` a few times to generate history."); + } + return Ok(()); + } + + let stats = budget_trend_stats_or_err(&records)?; + let cpu_values: Vec = records.iter().map(|r| r.cpu_used).collect(); + let mem_values: Vec = records.iter().map(|r| r.memory_used).collect(); + + if !Formatter::is_quiet() { + println!("Budget Trend"); + println!( + "Filters: contract={} function={}", + contract.unwrap_or("*"), + function.unwrap_or("*") + ); + println!( + "Regression params: threshold>{:.1}% lookback={} smoothing={}", + regression.threshold_pct, regression.lookback, regression.smoothing_window + ); + println!( + "Runs: {} Range: {} -> {}", + stats.count, stats.first_date, stats.last_date + ); + println!( + "CPU insns: last={} avg={} min={} max={}", + crate::inspector::budget::BudgetInspector::format_cpu_insns(stats.last_cpu), + crate::inspector::budget::BudgetInspector::format_cpu_insns(stats.cpu_avg), + crate::inspector::budget::BudgetInspector::format_cpu_insns(stats.cpu_min), + crate::inspector::budget::BudgetInspector::format_cpu_insns(stats.cpu_max) + ); + println!( + "Mem bytes: last={} avg={} min={} max={}", + crate::inspector::budget::BudgetInspector::format_memory_bytes(stats.last_mem), + crate::inspector::budget::BudgetInspector::format_memory_bytes(stats.mem_avg), + crate::inspector::budget::BudgetInspector::format_memory_bytes(stats.mem_min), + crate::inspector::budget::BudgetInspector::format_memory_bytes(stats.mem_max) + ); + println!(); + println!("CPU trend: {}", Formatter::sparkline(&cpu_values, 50)); + println!("MEM trend: {}", Formatter::sparkline(&mem_values, 50)); + + if let Some((cpu_reg, mem_reg)) = + crate::history::check_regression_with_config(&records, ®ression) + { + if cpu_reg > 0.0 || mem_reg > 0.0 { + println!(); + println!("Regression warning (latest vs baseline):"); + if cpu_reg > 0.0 { + println!(" CPU increased by {:.1}%", cpu_reg); + } + if mem_reg > 0.0 { + println!(" Memory increased by {:.1}%", mem_reg); + } + } + } + } + + Ok(()) +} + +/// Prune run history according to retention policy. +pub fn history_prune(args: HistoryPruneArgs) -> Result<()> { + let policy = crate::history::RetentionPolicy { + max_records: args.max_records, + max_age_days: args.max_age_days, + }; + + if policy.is_empty() { + if !Formatter::is_quiet() { + println!("No retention policy specified. Use --max-records and/or --max-age-days."); + } + return Ok(()); + } + + let manager = HistoryManager::new()?; + + if args.dry_run { + let mut records = manager.load_history()?; + let before = records.len(); + HistoryManager::apply_retention(&mut records, &policy); + let remaining = records.len(); + let removed = before.saturating_sub(remaining); + + if !Formatter::is_quiet() { + if removed == 0 { + println!("[dry-run] Nothing removed ({} records).", remaining); + } else { + println!( + "[dry-run] Would remove {} record(s). {} record(s) remaining.", + removed, remaining + ); + } + } + return Ok(()); + } + + let report = manager.prune_history(&policy)?; + if !Formatter::is_quiet() { + if report.removed == 0 { + println!("Nothing removed ({} records).", report.remaining); + } else { + println!( + "Removed {} record(s). {} record(s) remaining.", + report.removed, report.remaining + ); + } + } + Ok(()) +} + +pub fn doctor(args: crate::cli::args::DoctorArgs) -> Result<()> { + let history_path = compute_default_history_path() + .unwrap_or_else(|_| std::env::temp_dir().join("soroban-debug-history.json")); + + let remote = if let Some(addr) = args.remote.as_ref() { + let config = crate::client::remote_client::RemoteClientConfig { + connect_timeout: std::time::Duration::from_millis(args.timeout_ms), + timeouts: crate::client::remote_client::RemoteClientConfig::build_timeouts( + args.timeout_ms, + Some(args.timeout_ms), + Some(args.timeout_ms), + ), + ..crate::client::remote_client::RemoteClientConfig::default() + }; + + match crate::client::remote_client::RemoteClient::connect_with_config( + addr, + args.token.clone(), + config, + ) { + Ok(mut client) => { + let ping = match client.ping() { + Ok(_) => Some(check_ok("Ping succeeded")), + Err(e) => Some(check_err(format!("Ping failed: {}", e))), + }; + Some(RemoteDoctorReport { + address: addr.clone(), + connect: check_ok(format!("Connected to {}", addr)), + handshake: Some(check_ok("Handshake succeeded")), + ping, + auth: args + .token + .as_ref() + .map(|_| check_ok("Authentication succeeded")), + selected_protocol: None, + }) + } + Err(e) => Some(RemoteDoctorReport { + address: addr.clone(), + connect: check_err(format!("Connection failed: {}", e)), + handshake: None, + ping: None, + auth: None, + selected_protocol: None, + }), + } + } else { + None + }; + + let report = DoctorReport { + binary: binary_status(), + config: config_status(), + history: history_file_status(&history_path), + plugins: plugin_status(), + protocol: protocol_status(), + remote, + vscode_extension: vscode_extension_status(args.vscode_manifest.as_ref()), + }; + + if args.format == OutputFormat::Json { + let json = crate::output::to_json_string(&report) + .map_err(|e| miette::miette!("Failed to serialize doctor report: {}", e))?; + println!("{}", json); + return Ok(()); + } + + println!("Binary: {}", report.binary); + println!("Config: {}", report.config); + println!("History: {}", report.history); + println!("Plugins: {}", report.plugins); + println!("Protocol: {}", report.protocol); + if let Some(remote) = report.remote { + println!("Remote connect: {}", remote.connect.message); + if let Some(handshake) = remote.handshake { + println!("Remote handshake: {}", handshake.message); + } + if let Some(ping) = remote.ping { + println!("Remote ping: {}", ping.message); + } + if let Some(auth) = remote.auth { + println!("Remote auth: {}", auth.message); + } + } + println!("VS Code extension: {}", report.vscode_extension); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn budget_trend_stats_or_err_returns_error_instead_of_panicking() { + let empty: Vec = Vec::new(); + let err = budget_trend_stats_or_err(&empty).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Failed to compute budget trend statistics")); + } + + #[test] + fn doctor_report_serializes_with_expected_sections() { + let history_path = std::env::temp_dir().join("soroban-debug-doctor-history.json"); + let report = DoctorReport { + binary: binary_status(), + config: config_status(), + history: history_file_status(&history_path), + plugins: plugin_status(), + protocol: protocol_status(), + remote: None, + vscode_extension: vscode_extension_status(None), + }; + + let json = serde_json::to_value(&report).unwrap(); + assert!(json.get("binary").is_some()); + assert!(json.get("config").is_some()); + assert!(json.get("history").is_some()); + assert!(json.get("plugins").is_some()); + assert!(json.get("protocol").is_some()); + assert!(json.get("vscode_extension").is_some()); + } +} +// +/////// + assert!(json.get("binary").is_some()); + assert!(json.get("config").is_some()); + assert!(json.get("history").is_some()); + assert!(json.get("plugins").is_some()); + assert!(json.get("protocol").is_some()); + assert!(json.get("vscode_extension").is_some()); + } +} +// +/////// diff --git a/src/client/remote_client.rs b/src/client/remote_client.rs index d0c2aba1..ed71c042 100644 --- a/src/client/remote_client.rs +++ b/src/client/remote_client.rs @@ -123,6 +123,10 @@ pub struct RemoteClient { /// Session identifier received from the server during the initial handshake. /// Used to reconnect to an existing session after a transient disconnect. session_id: Option, + /// The protocol version selected during the handshake. + selected_protocol_version: Option, + /// Metadata about the current session received from the server. + session_info: Option, } #[derive(Debug)] @@ -196,6 +200,8 @@ impl RemoteClient { selected_protocol_version: None, session_info: None, session_id: None, + selected_protocol_version: None, + session_info: None, }; client.handshake("rust-remote-client", env!("CARGO_PKG_VERSION"))?; @@ -361,11 +367,17 @@ impl RemoteClient { heartbeat_interval_ms: self.config.heartbeat_interval_ms, idle_timeout_ms: self.config.idle_timeout_ms, session_label: self.config.session_label.clone(), + reconnect_session_id: None, })?; match response { DebugResponse::HandshakeAck { selected_version, + server_capabilities, + .. + } => { + self.selected_protocol_version = Some(selected_version); + self.negotiated_capabilities = Some(server_capabilities); session_id, session_created_at, session_label, @@ -385,6 +397,16 @@ impl RemoteClient { }); Ok(selected_version) } + DebugResponse::IncompatibleCapabilities { + message, + missing_capabilities, + .. + } => Err(DebuggerError::ExecutionError(format!( + "Server is missing required capabilities [{}]: {}", + missing_capabilities.join(", "), + message + )) + .into()), DebugResponse::IncompatibleProtocol { message, .. } => { Err(DebuggerError::ExecutionError(format!( "Incompatible debugger protocol: {}", @@ -428,6 +450,33 @@ impl RemoteClient { } } + /// Returns an error if `cap_name` is not in the negotiated server capabilities. + /// Call this at the top of any method that uses an optional feature. + fn require_capability(&self, cap_name: &str) -> Result<()> { + let caps = match &self.negotiated_capabilities { + Some(c) => c, + None => return Ok(()), // handshake not yet done; let the server reject it + }; + let supported = match cap_name { + "evaluate" => caps.evaluate, + "source_breakpoints" => caps.source_breakpoints, + "conditional_breakpoints" => caps.conditional_breakpoints, + "snapshot_loading" => caps.snapshot_loading, + "dynamic_trace_events" => caps.dynamic_trace_events, + "repeat_execution" => caps.repeat_execution, + _ => true, // unknown names pass through + }; + if supported { + Ok(()) + } else { + Err(DebuggerError::ExecutionError(format!( + "Server does not support '{}'. Check server version or capabilities.", + cap_name + )) + .into()) + } + } + /// Load a contract on the server pub fn load_contract(&mut self, contract_path: &str) -> Result { let response = self.send_request(DebugRequest::LoadContract { @@ -695,6 +744,7 @@ impl RemoteClient { /// Load network snapshot pub fn load_snapshot(&mut self, snapshot_path: &str) -> Result { + self.require_capability("snapshot_loading")?; let response = self.send_request(DebugRequest::LoadSnapshot { snapshot_path: snapshot_path.to_string(), })?; @@ -718,6 +768,7 @@ impl RemoteClient { expression: &str, frame_id: Option, ) -> Result<(String, Option)> { + self.require_capability("evaluate")?; let response = self.send_request_with_retry( DebugRequest::Evaluate { expression: expression.to_string(), @@ -804,6 +855,7 @@ impl RemoteClient { heartbeat_interval_ms: Some(30000), idle_timeout_ms: Some(60000), session_label: self.config.session_label.clone(), + reconnect_session_id: self.session_id.clone(), }; // Use a standard timeout for handshake during reconnect let handshake_resp = self @@ -814,9 +866,7 @@ impl RemoteClient { // Capture session_id from reconnect handshake if let DebugResponse::HandshakeAck { session_id, .. } = &handshake_resp { - if session_id.is_some() { - self.session_id = session_id.clone(); - } + self.session_id = Some(session_id.clone()); } if let Some(token) = self.token.clone() { diff --git a/src/config.rs b/src/config.rs index ed786c66..d842e986 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,11 +14,50 @@ pub struct Config { #[serde(default)] pub output: OutputConfig, #[serde(default)] + pub keybindings: KeybindingsConfig, + #[serde(default)] + pub repl_settings: ReplSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ReplSettings { + #[serde(default)] + pub history_file: Option, + #[serde(default)] + pub save_history: Option, pub keybindings: Keybindings, #[serde(default)] pub repl: ReplConfig, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeybindingsConfig { + #[serde(default = "default_step_key")] + pub step: String, + #[serde(default = "default_continue_key")] + pub continue_exec: String, + #[serde(default = "default_inspect_key")] + pub inspect: String, + #[serde(default = "default_quit_key")] + pub quit: String, +} + +impl Default for KeybindingsConfig { + fn default() -> Self { + Self { + step: default_step_key(), + continue_exec: default_continue_key(), + inspect: default_inspect_key(), + quit: default_quit_key(), + } + } +} + +fn default_step_key() -> String { "s".to_string() } +fn default_continue_key() -> String { "c".to_string() } +fn default_inspect_key() -> String { "i".to_string() } +fn default_quit_key() -> String { "q".to_string() } + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct DebugConfig { /// Default breakpoints to set diff --git a/src/debugger/breakpoint.rs b/src/debugger/breakpoint.rs index 7f8cc94f..507345ec 100644 --- a/src/debugger/breakpoint.rs +++ b/src/debugger/breakpoint.rs @@ -15,7 +15,8 @@ pub struct Breakpoint { /// Optional log message with variable interpolation (e.g., "Balance: {balance}") pub log_message: Option, /// Number of times this breakpoint has been hit - pub hit_count: usize, + #[serde(default)] + pub hit_count: u64, } impl Breakpoint { @@ -89,6 +90,8 @@ pub struct BreakpointSpec { #[derive(Debug, Clone, Default)] pub struct BreakpointHit { + pub breakpoint_id: String, + pub hit_count: u64, pub should_pause: bool, pub log_messages: Vec, pub pause_reason: Option, @@ -264,6 +267,8 @@ impl BreakpointManager { .into_iter() .collect(); Ok(Some(BreakpointHit { + breakpoint_id: bp.id.clone(), + hit_count: bp.hit_count, should_pause: !bp.is_log_point(), log_messages, pause_reason: (!bp.is_log_point()).then(|| "breakpoint".to_string()), @@ -379,12 +384,12 @@ fn interpolate_log_message( } /// Evaluate a hit condition against the current hit count -fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Result { +fn evaluate_hit_condition(hit_condition: &str, hit_count: u64) -> crate::Result { let hit_condition = hit_condition.trim(); // Format: >N, >=N, ==N, =N) if let Some(stripped) = hit_condition.strip_prefix(">=") { - let n: usize = stripped.trim().parse().map_err(|_| { + let n: u64 = stripped.trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid number in hit condition: {}", stripped @@ -394,7 +399,7 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul } if let Some(stripped) = hit_condition.strip_prefix('>') { - let n: usize = stripped.trim().parse().map_err(|_| { + let n: u64 = stripped.trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid number in hit condition: {}", stripped @@ -404,7 +409,7 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul } if let Some(stripped) = hit_condition.strip_prefix("==") { - let n: usize = stripped.trim().parse().map_err(|_| { + let n: u64 = stripped.trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid number in hit condition: {}", stripped @@ -414,7 +419,7 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul } if let Some(stripped) = hit_condition.strip_prefix("<=") { - let n: usize = stripped.trim().parse().map_err(|_| { + let n: u64 = stripped.trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid number in hit condition: {}", stripped @@ -424,7 +429,7 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul } if let Some(stripped) = hit_condition.strip_prefix('<') { - let n: usize = stripped.trim().parse().map_err(|_| { + let n: u64 = stripped.trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid number in hit condition: {}", stripped @@ -439,13 +444,13 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul if parts.len() == 2 { let rest: Vec<&str> = parts[1].split("==").collect(); if rest.len() == 2 { - let n: usize = rest[0].trim().parse().map_err(|_| { + let n: u64 = rest[0].trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid modulo in hit condition: {}", rest[0] )) })?; - let expected: usize = rest[1].trim().parse().map_err(|_| { + let expected: u64 = rest[1].trim().parse().map_err(|_| { crate::DebuggerError::BreakpointError(format!( "Invalid value in hit condition: {}", rest[1] @@ -463,7 +468,7 @@ fn evaluate_hit_condition(hit_condition: &str, hit_count: usize) -> crate::Resul } // Plain number means "break when hit count >= N" - if let Ok(n) = hit_condition.parse::() { + if let Ok(n) = hit_condition.parse::() { return Ok(hit_count >= n); } @@ -510,7 +515,7 @@ fn is_valid_hit_condition(s: &str) -> bool { } // Check if it's just a number - s.parse::().is_ok() + s.parse::().is_ok() } impl Default for BreakpointManager { @@ -613,6 +618,7 @@ mod tests { manager.add("transfer"); assert!(manager.should_break("transfer")); assert!(!manager.should_break("mint")); + assert_eq!(manager.get("transfer").unwrap().hit_count, 0); } #[test] @@ -959,6 +965,79 @@ mod tests { assert_eq!(manager.get("transfer").unwrap().hit_count, 3); } + #[test] + fn test_hit_counts_are_independent() { + let mut manager = BreakpointManager::new(); + manager.add("transfer"); + manager.add("mint"); + let evaluator = MockEvaluator::new(); + + manager + .should_break_with_context("transfer", &evaluator) + .unwrap(); + manager + .should_break_with_context("transfer", &evaluator) + .unwrap(); + manager + .should_break_with_context("mint", &evaluator) + .unwrap(); + + assert_eq!(manager.get("transfer").unwrap().hit_count, 2); + assert_eq!(manager.get("mint").unwrap().hit_count, 1); + } + + #[test] + fn test_missing_or_removed_breakpoint_does_not_increment() { + let mut manager = BreakpointManager::new(); + manager.add("transfer"); + assert!(manager.remove("transfer")); + + let evaluator = MockEvaluator::new(); + let (should_break, log) = manager + .should_break_with_context("transfer", &evaluator) + .unwrap(); + + assert!(!should_break); + assert!(log.is_none()); + assert!(manager.get("transfer").is_none()); + } + + #[test] + fn test_on_hit_returns_hit_count_metadata() { + let mut manager = BreakpointManager::new(); + manager.add_spec(BreakpointSpec { + id: "bp-1".to_string(), + function: "transfer".to_string(), + condition: None, + hit_condition: None, + log_message: None, + }); + + let first = manager + .on_hit("transfer", &HashMap::new(), None) + .unwrap() + .unwrap(); + let second = manager + .on_hit("transfer", &HashMap::new(), None) + .unwrap() + .unwrap(); + + assert_eq!(first.breakpoint_id, "bp-1"); + assert_eq!(first.hit_count, 1); + assert_eq!(second.breakpoint_id, "bp-1"); + assert_eq!(second.hit_count, 2); + } + + #[test] + fn test_breakpoint_json_includes_hit_count() { + let mut breakpoint = Breakpoint::simple("transfer".to_string()); + breakpoint.increment_hit(); + + let value = serde_json::to_value(&breakpoint).unwrap(); + + assert_eq!(value["hit_count"], 1); + } + #[test] fn test_log_point_does_not_pause() { let mut manager = BreakpointManager::new(); diff --git a/src/debugger/engine.rs b/src/debugger/engine.rs index 5c0ff3c8..f3d2ac34 100644 --- a/src/debugger/engine.rs +++ b/src/debugger/engine.rs @@ -29,6 +29,109 @@ pub struct DebuggerEngine { instruction_debug_enabled: bool, } +struct EngineConditionEvaluator { + storage: HashMap, + function: String, + args: Option, +} + +impl EngineConditionEvaluator { + fn new(storage: HashMap, function: &str, args: Option<&str>) -> Self { + Self { + storage, + function: function.to_string(), + args: args.map(str::to_string), + } + } + + fn parse_condition<'a>( + &self, + condition: &'a str, + ) -> crate::Result<(&'a str, &'a str, &'a str)> { + let condition = condition.trim(); + let (var, op, value) = if let Some(pos) = condition.find(">=") { + let (var, rest) = condition.split_at(pos); + (var.trim(), ">=", rest[2..].trim()) + } else if let Some(pos) = condition.find("<=") { + let (var, rest) = condition.split_at(pos); + (var.trim(), "<=", rest[2..].trim()) + } else if let Some(pos) = condition.find("==") { + let (var, rest) = condition.split_at(pos); + (var.trim(), "==", rest[2..].trim()) + } else if let Some(pos) = condition.find("!=") { + let (var, rest) = condition.split_at(pos); + (var.trim(), "!=", rest[2..].trim()) + } else if let Some(pos) = condition.find('>') { + let (var, rest) = condition.split_at(pos); + (var.trim(), ">", rest[1..].trim()) + } else if let Some(pos) = condition.find('<') { + let (var, rest) = condition.split_at(pos); + (var.trim(), "<", rest[1..].trim()) + } else { + return Err(crate::DebuggerError::BreakpointError(format!( + "No operator found in condition: {}", + condition + )) + .into()); + }; + + Ok((var, op, value)) + } + + fn normalize_value(value: &str) -> &str { + value.trim_matches('"').trim_matches('\'') + } +} + +impl ConditionEvaluator for EngineConditionEvaluator { + fn evaluate(&self, condition: &str) -> crate::Result { + let (var, op, value_str) = self.parse_condition(condition)?; + let actual = self + .storage + .get(var) + .map(String::as_str) + .unwrap_or_default() + .trim(); + let actual = Self::normalize_value(actual); + let expected = Self::normalize_value(value_str); + + if let (Ok(lhs), Ok(rhs)) = (actual.parse::(), expected.parse::()) { + return Ok(match op { + "==" => lhs == rhs, + "!=" => lhs != rhs, + ">" => lhs > rhs, + "<" => lhs < rhs, + ">=" => lhs >= rhs, + "<=" => lhs <= rhs, + _ => false, + }); + } + + Ok(match op { + "==" => actual == expected, + "!=" => actual != expected, + ">" => actual > expected, + "<" => actual < expected, + ">=" => actual >= expected, + "<=" => actual <= expected, + _ => false, + }) + } + + fn interpolate_log(&self, template: &str) -> crate::Result { + let mut rendered = template.to_string(); + for (key, value) in &self.storage { + rendered = rendered.replace(&format!("{{{}}}", key), value); + } + rendered = rendered.replace("{function}", &self.function); + if let Some(args) = &self.args { + rendered = rendered.replace("{args}", args); + rendered = rendered.replace("{arguments}", args); + } + Ok(rendered) + } +} + impl DebuggerEngine { /// Returns the current paused source location (file, line, column) if available. pub fn current_source_location(&self) -> Option { @@ -202,6 +305,23 @@ impl DebuggerEngine { ); if check_breakpoints { + let storage = self.executor.get_storage_snapshot().unwrap_or_default(); + let evaluator = EngineConditionEvaluator::new(storage, function, args); + let (should_pause, log_output) = self + .breakpoints_mut() + .should_break_with_context(function, &evaluator)?; + + if let Some(message) = log_output { + crate::logging::log_breakpoint_log(function, &message); + println!("{message}"); + } + + if should_pause { + let condition = self + .breakpoints() + .get_breakpoint(function) + .and_then(|bp| bp.condition.clone()); + self.pause_at_function(function, condition); let evaluator = self.create_condition_evaluator(); match self .breakpoints @@ -294,11 +414,17 @@ impl DebuggerEngine { .breakpoints .get_breakpoint(function) .and_then(|bp| bp.condition.as_ref().map(|c| format!("{:?}", c))); + let hit_count = self + .breakpoints + .get_breakpoint(function) + .map(|bp| bp.hit_count) + .unwrap_or(0); crate::plugin::registry::dispatch_global_event( &ExecutionEvent::BreakpointHit { function: function.to_string(), condition, + hit_count, }, &mut plugin_ctx, ); @@ -581,6 +707,11 @@ impl DebuggerEngine { &ExecutionEvent::BreakpointHit { function: function.to_string(), condition, + hit_count: self + .breakpoints + .get_breakpoint(function) + .map(|bp| bp.hit_count) + .unwrap_or(0), }, &mut plugin_ctx, ); diff --git a/src/debugger/engine_test.rs b/src/debugger/engine_test.rs index c27b4b30..e4660321 100644 --- a/src/debugger/engine_test.rs +++ b/src/debugger/engine_test.rs @@ -3,7 +3,13 @@ use super::DebuggerEngine; fn create_test_engine() -> DebuggerEngine { let wasm_bytes = include_bytes!("../../tests/fixtures/wasm/echo.wasm").to_vec(); let executor = crate::runtime::executor::ContractExecutor::new(wasm_bytes).unwrap(); - DebuggerEngine::new(executor, vec![]) + DebuggerEngine::new(executor, vec![], vec![]) +} + +fn create_counter_engine(initial_breakpoints: Vec) -> DebuggerEngine { + let wasm_bytes = include_bytes!("../../tests/fixtures/wasm/counter.wasm").to_vec(); + let executor = crate::runtime::executor::ContractExecutor::new(wasm_bytes).unwrap(); + DebuggerEngine::new(executor, initial_breakpoints, vec![]) } #[test] @@ -17,3 +23,28 @@ fn no_source_location_without_instruction_state() { let engine = create_test_engine(); assert!(engine.current_source_location().is_none()); } + +#[test] +fn execute_increments_breakpoint_hit_count_once_per_hit() { + let mut engine = create_counter_engine(vec!["get".to_string()]); + + let _ = engine.execute("get", None); + assert_eq!( + engine + .breakpoints() + .get_breakpoint("get") + .unwrap() + .hit_count, + 1 + ); + + let _ = engine.execute("get", None); + assert_eq!( + engine + .breakpoints() + .get_breakpoint("get") + .unwrap() + .hit_count, + 2 + ); +} diff --git a/src/debugger/source_map.rs b/src/debugger/source_map.rs index 8da83b86..8bcd4439 100644 --- a/src/debugger/source_map.rs +++ b/src/debugger/source_map.rs @@ -1072,3 +1072,4 @@ mod tests { ); } } + diff --git a/src/debugger/timeline.rs b/src/debugger/timeline.rs index 5580b185..10926068 100644 --- a/src/debugger/timeline.rs +++ b/src/debugger/timeline.rs @@ -191,6 +191,10 @@ pub struct TimelinePausePoint { /// Monotonic sequence number within this artifact. pub index: usize, pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub breakpoint_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hit_count: Option, pub location: Option, /// Call stack snapshot at the pause point (best-effort). pub call_stack: Vec, @@ -289,3 +293,25 @@ impl TimelineStorageDelta { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pause_point_serializes_breakpoint_hit_count() { + let pause = TimelinePausePoint { + index: 0, + reason: "breakpoint".to_string(), + breakpoint_id: Some("transfer".to_string()), + hit_count: Some(3), + location: None, + call_stack: Vec::new(), + }; + + let value = serde_json::to_value(pause).unwrap(); + + assert_eq!(value["breakpoint_id"], "transfer"); + assert_eq!(value["hit_count"], 3); + } +} diff --git a/src/main.rs b/src/main.rs index ae6e1e13..94eb1f7b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -141,6 +141,13 @@ fn main() -> miette::Result<()> { .map_err(|e: std::io::Error| miette::miette!(e)) .and_then(|rt| rt.block_on(soroban_debugger::cli::commands::repl(args))) } + Some(Commands::PluginTrustReport(args)) => { + soroban_debugger::cli::commands::plugin_trust_report(args) + } + Some(Commands::PluginInspect(args)) => { + soroban_debugger::cli::commands::plugin_inspect(args) + } + Some(Commands::Doctor(args)) => soroban_debugger::cli::commands::doctor(args), Some(Commands::External(argv)) => { if argv.is_empty() { return Err(miette::miette!("Missing plugin subcommand")); @@ -290,6 +297,16 @@ fn get_command_name(cli: &Cli) -> String { } if let Err(err) = result { + let err: miette::Report = err; + if run_json_output_requested { + let mut message = err.to_string(); + if let Some(help) = err.help() { + message.push_str(&format!(" | hint: {}", help)); + } + let output = soroban_debugger::output::VersionedOutput::::error( + "run", message, + ); + if let Ok(json) = serde_json::to_string_pretty(&output) { if is_json { let message = err.to_string(); let code = err.code().map(|c| c.to_string()); diff --git a/src/output.rs b/src/output.rs index 361b2b64..67e1f6b1 100644 --- a/src/output.rs +++ b/src/output.rs @@ -555,6 +555,7 @@ impl OutputConfig { } } + /// Status kind for text-equivalent labels (screen reader friendly). #[derive(Clone, Copy)] pub enum StatusLabel { diff --git a/src/plugin/README.md b/src/plugin/README.md index fdfb0f57..c040e879 100644 --- a/src/plugin/README.md +++ b/src/plugin/README.md @@ -124,7 +124,7 @@ Plugins can hook into these events: - `BeforeFunctionCall` / `AfterFunctionCall` - `BeforeInstruction` / `AfterInstruction` -- `BreakpointHit` +- `BreakpointHit` (includes the breakpoint hit count) - `ExecutionPaused` / `ExecutionResumed` - `StorageAccess` - `DiagnosticEvent` diff --git a/src/plugin/events.rs b/src/plugin/events.rs index c9df48aa..1c3df129 100644 --- a/src/plugin/events.rs +++ b/src/plugin/events.rs @@ -28,6 +28,8 @@ pub enum ExecutionEvent { BreakpointHit { function: String, condition: Option, + #[serde(default)] + hit_count: u64, }, /// Fired when execution is paused diff --git a/src/plugin/loader.rs b/src/plugin/loader.rs index b06c6afd..e0d916e2 100644 --- a/src/plugin/loader.rs +++ b/src/plugin/loader.rs @@ -14,6 +14,24 @@ pub struct PluginRuntimeDescriptor { pub trusted: bool, } +/// Policy governing what capabilities a plugin is allowed to register or use +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PluginSandboxPolicy { + pub allow_command_registration: bool, + pub allow_formatter_registration: bool, + pub allow_execution_hooks: bool, +} + +impl Default for PluginSandboxPolicy { + fn default() -> Self { + Self { + allow_command_registration: true, + allow_formatter_registration: true, + allow_execution_hooks: true, + } + } +} + /// A loaded plugin instance pub struct LoadedPlugin { /// The plugin instance @@ -93,6 +111,7 @@ pub struct PluginLoader { /// Trust policy used before dynamic loading trust_policy: PluginTrustPolicy, + /// Sandbox policy used for capability containment /// Sandbox policy applied before enabling plugin capabilities. sandbox_policy: PluginSandboxPolicy, } @@ -170,6 +189,18 @@ impl PluginLoader { Self::with_policies(plugin_dir, trust_policy, PluginSandboxPolicy::default()) } + pub fn with_policies( + plugin_dir: PathBuf, + trust_policy: PluginTrustPolicy, + sandbox_policy: PluginSandboxPolicy, + ) -> Self { + Self { + plugin_dir, + trust_policy, + sandbox_policy: PluginSandboxPolicy::default(), + } + } + pub fn with_policies( plugin_dir: PathBuf, trust_policy: PluginTrustPolicy, diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs index 5e09dd31..3957f890 100644 --- a/src/plugin/registry.rs +++ b/src/plugin/registry.rs @@ -108,6 +108,42 @@ pub fn format_global_output(formatter: &str, data: &str) -> PluginResult, + pub fingerprint: Option, + pub warnings: Vec, + pub capabilities: super::manifest::PluginCapabilities, +} + +pub fn get_global_trust_report() -> Vec { + let Some(registry) = GLOBAL_PLUGIN_REGISTRY.get() else { + return Vec::new(); + }; + + let Ok(registry) = registry.read() else { + return Vec::new(); + }; + + registry.trust_report() +} + +pub fn get_global_plugin_info(name: &str) -> Option { + let Some(registry) = GLOBAL_PLUGIN_REGISTRY.get() else { + return None; + }; + + let Ok(registry) = registry.read() else { + return None; + }; + + registry.plugin_info(name) +} + pub fn global_command_conflicts() -> HashMap> { let Some(registry) = GLOBAL_PLUGIN_REGISTRY.get() else { return HashMap::new(); @@ -994,6 +1030,46 @@ impl PluginRegistry { out } + pub fn trust_report(&self) -> Vec { + let mut report = Vec::new(); + for plugin in self.plugins.values() { + if let Ok(p) = plugin.read() { + let trust = p.trust(); + report.push(PluginTrustSummary { + name: p.manifest().name.clone(), + version: p.manifest().version.clone(), + author: p.manifest().author.clone(), + trusted: trust.trusted, + signer: trust.signer.as_ref().map(|s| s.signer.clone()), + fingerprint: trust.signer.as_ref().map(|s| s.fingerprint.clone()), + warnings: trust.warnings.clone(), + capabilities: p.manifest().capabilities.clone(), + }); + } + } + report + } + + pub fn plugin_info(&self, name: &str) -> Option { + self.plugins.get(name).and_then(|plugin| { + if let Ok(p) = plugin.read() { + let trust = p.trust(); + Some(PluginTrustSummary { + name: p.manifest().name.clone(), + version: p.manifest().version.clone(), + author: p.manifest().author.clone(), + trusted: trust.trusted, + signer: trust.signer.as_ref().map(|s| s.signer.clone()), + fingerprint: trust.signer.as_ref().map(|s| s.fingerprint.clone()), + warnings: trust.warnings.clone(), + capabilities: p.manifest().capabilities.clone(), + }) + } else { + None + } + }) + } + /// Execute a plugin-provided command, if any plugin declares it. pub fn execute_command(&self, command: &str, args: &[String]) -> PluginResult> { let key = Self::normalize_plugin_item_name(command); diff --git a/src/repeat.rs b/src/repeat.rs index b6ac5c2b..91f9ad64 100644 --- a/src/repeat.rs +++ b/src/repeat.rs @@ -257,7 +257,7 @@ impl RepeatRunner { executor.set_initial_storage(storage.clone())?; } - let mut engine = DebuggerEngine::new(executor, self.breakpoints.clone()); + let mut engine = DebuggerEngine::new(executor, self.breakpoints.clone(), vec![]); let start = Instant::now(); let result = engine.execute(function, args)?; diff --git a/src/repl/commands.rs b/src/repl/commands.rs index d8d79062..90c9cfc8 100644 --- a/src/repl/commands.rs +++ b/src/repl/commands.rs @@ -27,7 +27,7 @@ pub enum ReplCommand { function: String, condition: Option, }, - /// List breakpoints: list-breaks + /// List breakpoints and hit counts: list-breaks ListBreaks, /// Clear a breakpoint: clear-break ClearBreak { @@ -36,6 +36,7 @@ pub enum ReplCommand { /// Open the command palette Palette, Functions, + Palette, } impl ReplCommand { @@ -54,6 +55,7 @@ impl ReplCommand { "clear-break", "palette", "functions", + "palette", ] } @@ -120,6 +122,7 @@ impl ReplCommand { "functions" => Ok(ReplCommand::Functions), "clear" => Ok(ReplCommand::Clear), "help" => Ok(ReplCommand::Help), + "palette" => Ok(ReplCommand::Palette), "exit" | "quit" => Ok(ReplCommand::Exit), _ => Err(miette::miette!( "Unknown command: '{}'. Type 'help' for available commands.", diff --git a/src/repl/executor.rs b/src/repl/executor.rs index f4a38a26..b27a0c68 100644 --- a/src/repl/executor.rs +++ b/src/repl/executor.rs @@ -35,7 +35,8 @@ impl ReplExecutor { .map(|sig| (sig.name.clone(), sig)) .collect(); let executor = ContractExecutor::new(wasm_bytes)?; - let mut engine = crate::debugger::engine::DebuggerEngine::new(executor, Vec::new()); + let mut engine = + crate::debugger::engine::DebuggerEngine::new(executor, Vec::new(), Vec::new()); engine.executor_mut().enable_mock_all_auths(); if let Some(snapshot_path) = &config.network_snapshot { @@ -87,12 +88,25 @@ impl ReplExecutor { // Check if we should break before starting if self.engine.breakpoints().should_break(function) { - self.engine.prepare_breakpoint_stop(function, args_ref); - crate::logging::log_display( - format!("Execution paused at function: {}", function), - crate::logging::LogLevel::Warn, - ); - return Ok(()); + let storage = self.engine.executor().get_storage_snapshot()?; + if let Some(hit) = self + .engine + .breakpoints_mut() + .on_hit(function, &storage, args_ref)? + { + for message in hit.log_messages { + crate::logging::log_display(message, crate::logging::LogLevel::Info); + } + + if hit.should_pause { + self.engine.prepare_breakpoint_stop(function, args_ref); + crate::logging::log_display( + format!("Execution paused at function: {}", function), + crate::logging::LogLevel::Warn, + ); + return Ok(()); + } + } } let storage_before = self.engine.executor().get_storage_snapshot()?; diff --git a/src/repl/session.rs b/src/repl/session.rs index 8e5903a3..6a47594f 100644 --- a/src/repl/session.rs +++ b/src/repl/session.rs @@ -118,6 +118,9 @@ impl ReplSession { /// Create a new REPL session pub fn new(config: ReplConfig) -> Result { let global_config = crate::config::Config::load_or_default(); + let save_history = global_config.repl_settings.save_history.unwrap_or(true); + + let history_path = if let Some(path) = global_config.repl_settings.history_file { let save_history = global_config.repl.save_history.unwrap_or(true); let history_path = if let Some(path) = global_config.repl.history_file { @@ -286,7 +289,7 @@ impl ReplSession { .condition .map(|c| format!(" (if {:?})", c)) .unwrap_or_default(); - tracing::info!(" - {}{}", bp.function, cond); + tracing::info!(" - {}{} hits={}", bp.function, cond, bp.hit_count); } } Ok(false) @@ -361,7 +364,7 @@ impl ReplSession { Formatter::info("break") ); tracing::info!( - " {} List all active breakpoints", + " {} List all active breakpoints with hit counts", Formatter::info("list-breaks") ); tracing::info!( diff --git a/src/runtime/invoker.rs b/src/runtime/invoker.rs index 9a449808..636bb267 100644 --- a/src/runtime/invoker.rs +++ b/src/runtime/invoker.rs @@ -27,6 +27,7 @@ pub struct InvokeArgs<'a> { /// Invoke `function` on the already-registered contract at `contract_address`. #[allow(clippy::too_many_arguments)] +#[tracing::instrument(skip_all, fields(function = args.function))] #[tracing::instrument(skip_all, fields(function = %args.function))] pub fn invoke_function( env: &Env, diff --git a/src/scenario.rs b/src/scenario.rs index b6d32300..5e091d56 100644 --- a/src/scenario.rs +++ b/src/scenario.rs @@ -26,6 +26,7 @@ pub struct Scenario { #[derive(Debug, Default, Deserialize, Serialize, PartialEq, Eq)] pub struct ScenarioDefaults { + #[serde(alias = "timeout")] pub timeout_secs: Option, } @@ -34,6 +35,7 @@ pub struct ScenarioStep { pub name: Option, pub function: String, pub args: Option, + #[serde(alias = "timeout")] pub timeout_secs: Option, pub expected_return: Option, pub expected_storage: Option>, @@ -49,6 +51,8 @@ pub struct ScenarioStep { pub capture: Option, pub tags: Option>, pub notes: Option, + #[serde(default)] + pub skip: bool, } #[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -188,7 +192,7 @@ pub fn run_scenario(args: ScenarioArgs, _verbosity: Verbosity) -> Result<()> { Formatter::success(format!("Running {} scenario steps...\n", steps.len())) ); - let mut engine = DebuggerEngine::new(executor, vec![]); + let mut engine = DebuggerEngine::new(executor, vec![], vec![]); let mut all_passed = true; let mut variables: HashMap = HashMap::new(); @@ -255,6 +259,14 @@ pub fn run_scenario(args: ScenarioArgs, _verbosity: Verbosity) -> Result<()> { } } + if step.skip { + println!( + "{}", + Formatter::info(format!("Skipping Step {} ({}): skip field set to true", i + 1, step_label)) + ); + continue; + } + let effective_timeout = resolve_step_timeout( step.timeout_secs, root_scenario.defaults.timeout_secs, @@ -715,6 +727,23 @@ mod tests { ); } + #[test] + fn test_skip_field_deserialization() { + let toml_str = r#" + [[steps]] + function = "skipped" + skip = true + + [[steps]] + function = "run" + skip = false + "#; + + let scenario: Scenario = toml::from_str(toml_str).unwrap(); + assert!(scenario.steps[0].skip); + assert!(!scenario.steps[1].skip); + } + #[test] fn test_scenario_deserialization() { let toml_str = r#" diff --git a/src/server/debug_server.rs b/src/server/debug_server.rs index 9769a0c8..029823db 100644 --- a/src/server/debug_server.rs +++ b/src/server/debug_server.rs @@ -17,6 +17,7 @@ use chrono::Utc; use std::fs; use std::io::BufReader as StdBufReader; use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::io::AsyncBufReadExt; use tokio::net::TcpListener; @@ -52,6 +53,9 @@ pub struct DebugServer { last_disconnect: Option, /// Log of successful reconnection events in the current session. reconnection_log: ReconnectionLog, + mock_specs: Vec, + show_events: bool, + event_filter: Vec, } struct PendingExecution { @@ -105,6 +109,9 @@ impl DebugServer { session_label: None, last_disconnect: None, reconnection_log: ReconnectionLog::new(), + mock_specs, + show_events, + event_filter, }) } @@ -197,6 +204,13 @@ impl DebugServer { }; let (reader, writer) = tokio::io::split(stream); let mut reader = tokio::io::BufReader::new(reader); + let mut session_ctx = SessionContext { + info: RemoteSessionInfo { + session_id: self.session_id.clone(), + created_at: Utc::now().to_rfc3339(), + label: None, + }, + }; let (tx_in, mut rx_in) = tokio::sync::mpsc::unbounded_channel::(); let (tx_out, mut rx_out) = tokio::sync::mpsc::unbounded_channel::(); @@ -345,6 +359,7 @@ impl DebugServer { heartbeat_interval_ms, idle_timeout_ms, session_label, + reconnect_session_id: _, } = &request { if let Some(label) = session_label @@ -364,6 +379,29 @@ impl DebugServer { // Support heartbeat/timeout negotiation idle_timeout = *idle_timeout_ms; + // --- Capability negotiation (new block) --- + let our_caps = ServerCapabilities::current(); + if let Some(required) = required_capabilities { + let missing = required.unsupported_by(&our_caps); + if !missing.is_empty() { + let response = DebugMessage::response( + message.id, + DebugResponse::IncompatibleCapabilities { + message: format!( + "Server does not support required capabilities: {}. \ + Upgrade the server or disable these features on the client.", + missing.join(", ") + ), + missing_capabilities: missing.iter().map(|s| s.to_string()).collect(), + server_capabilities: our_caps, + }, + ); + send_msg(response)?; + return Ok(()); + } + } + // --- end capability negotiation --- + if let Some(interval) = *heartbeat_interval_ms { info!("Negotiated heartbeat interval: {}ms", interval); let tx_heartbeat = tx_out.clone(); @@ -401,6 +439,7 @@ impl DebugServer { session_label: session_ctx.info.label.clone(), heartbeat_interval_ms: *heartbeat_interval_ms, idle_timeout_ms: idle_timeout, + reconnect_id: Some(self.session_id.clone()), }, ); send_msg(response)?; @@ -595,7 +634,7 @@ impl DebugServer { Ok(bytes) => { match crate::runtime::executor::ContractExecutor::new(bytes.clone()) { Ok(executor) => { - let mut engine = DebuggerEngine::new(executor, Vec::new()); + let mut engine = DebuggerEngine::new(executor, vec![], vec![]); if !self.mock_specs.is_empty() { if let Err(e) = engine.executor_mut().set_mock_specs(&self.mock_specs) @@ -1344,6 +1383,7 @@ impl DebugServer { condition: breakpoint.condition.clone(), hit_condition: breakpoint.hit_condition.clone(), log_message: breakpoint.log_message.clone(), + hit_count: breakpoint.hit_count, }) .collect(), }, diff --git a/src/server/protocol.rs b/src/server/protocol.rs index d2cb21d3..7550febb 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -146,6 +146,8 @@ pub struct BreakpointDescriptor { pub condition: Option, pub hit_condition: Option, pub log_message: Option, + #[serde(default)] + pub hit_count: u64, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -172,6 +174,8 @@ pub enum DebugRequest { idle_timeout_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] session_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + reconnect_session_id: Option, }, /// Authenticate with the server @@ -295,6 +299,10 @@ pub enum DebugResponse { heartbeat_interval_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] idle_timeout_ms: Option, + /// Opaque session identifier the client can use to reconnect after a + /// transient disconnect. Absent on servers that do not support reconnection. + #[serde(default, skip_serializing_if = "Option::is_none")] + reconnect_id: Option, }, /// Handshake failed due to protocol mismatch. @@ -306,6 +314,15 @@ pub enum DebugResponse { protocol_max: u32, }, + /// Handshake rejected because the client requires capabilities the server doesn't support. + IncompatibleCapabilities { + message: String, + /// The capability names the client required but the server lacks. + missing_capabilities: Vec, + /// What the server does support, so the client can report it. + server_capabilities: ServerCapabilities, + }, + /// Authentication result Authenticated { success: bool, message: String }, @@ -353,6 +370,18 @@ pub enum DebugResponse { pause_reason: Option, }, + /// Acknowledgment of a successful session reconnection. + ReconnectAck { + session_id: String, + paused: bool, + current_function: Option, + breakpoints: Vec, + step_count: u64, + }, + + /// Reconnection failed because the session has expired or been purged. + SessionExpired { message: String }, + /// Inspection result InspectionResult { function: Option, @@ -601,4 +630,20 @@ mod tests { let event: DynamicTraceEvent = serde_json::from_str(json).unwrap(); assert_eq!(event.call_depth, Some(5)); } + + #[test] + fn breakpoint_descriptor_serializes_hit_count() { + let descriptor = BreakpointDescriptor { + id: "bp-1".to_string(), + function: "transfer".to_string(), + condition: None, + hit_condition: None, + log_message: None, + hit_count: 3, + }; + + let value = serde_json::to_value(descriptor).unwrap(); + + assert_eq!(value["hit_count"], 3); + } } diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index b52343aa..a089798e 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -355,11 +355,14 @@ impl DashboardApp { self.last_error.as_deref(), ); + let logs: Vec<_> = self let recent_alerts: Vec<_> = self .log_entries .iter() .filter(|entry| matches!(entry.level, LogLevel::Warn | LogLevel::Error)) .collect(); + + for entry in logs.iter().rev().take(8).rev() { for entry in recent_alerts .into_iter() .rev() @@ -661,6 +664,115 @@ impl DashboardApp { } } } + + fn clamp_storage_selection(&mut self) { + let len = self.storage_entries.len(); + if len == 0 { + self.storage_selected = 0; + self.storage_state.select(None); + } else { + self.storage_selected = self.storage_selected.min(len - 1); + self.storage_state.select(Some(self.storage_selected)); + } + } + + fn sync_storage_scroll_state(&mut self) { + let len = self.storage_entries.len(); + self.storage_scroll_state = self + .storage_scroll_state + .content_length(len) + .position(self.storage_selected); + } + + fn move_storage_selection(&mut self, delta: i32) { + let len = self.storage_entries.len(); + if len == 0 { return; } + let new_sel = if delta >= 0 { + self.storage_selected.saturating_add(delta as usize).min(len - 1) + } else { + self.storage_selected.saturating_sub(delta.abs() as usize) + }; + self.storage_selected = new_sel; + self.storage_state.select(Some(new_sel)); + self.sync_storage_scroll_state(); + } + + fn move_storage_page(&mut self, delta: i32) { + let page = self.storage_page_size; + self.move_storage_selection(delta * (page as i32)); + } + + fn move_storage_to_boundary(&mut self, end: bool) { + let len = self.storage_entries.len(); + if len == 0 { return; } + self.storage_selected = if end { len - 1 } else { 0 }; + self.storage_state.select(Some(self.storage_selected)); + self.sync_storage_scroll_state(); + } + + fn open_storage_input(&mut self, mode: StorageInputMode) { + self.storage_input_mode = Some(mode); + self.storage_input_value = String::new(); + } + + fn clear_storage_filter(&mut self) { + self.storage_filter = String::new(); + self.storage_input_mode = None; + self.refresh_state(); + } + + fn handle_storage_input_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + if let Some(mode) = self.storage_input_mode { + match key.code { + KeyCode::Esc => { + self.storage_input_mode = None; + } + KeyCode::Enter => { + match mode { + StorageInputMode::Filter => { + self.storage_filter = self.storage_input_value.clone(); + } + StorageInputMode::Jump => { + if let Ok(idx) = self.storage_input_value.parse::() { + self.storage_selected = idx.saturating_sub(1); + self.clamp_storage_selection(); + } + } + } + self.storage_input_mode = None; + self.refresh_state(); + } + KeyCode::Char(c) => { + self.storage_input_value.push(c); + } + KeyCode::Backspace => { + self.storage_input_value.pop(); + } + _ => {} + } + return true; + } + false + } + + fn storage_filtered_len(&self) -> usize { + self.storage_entries.len() + } + + fn storage_page(&self) -> crate::inspector::storage::StoragePage { + let entries = self.storage_entries.clone(); + let query = crate::inspector::storage::StorageQuery { + filter: if self.storage_filter.is_empty() { None } else { Some(self.storage_filter.clone()) }, + jump_to: None, + page: self.storage_selected / self.storage_page_size.max(1), + page_size: self.storage_page_size, + }; + crate::inspector::storage::StorageInspector::build_page(&entries, &query) + } + + fn set_storage_page_size(&mut self, size: usize) { + self.storage_page_size = size; + } } // ─── Main run loop ───────────────────────────────────────────────────────── diff --git a/src/ui/tui.rs b/src/ui/tui.rs index 9ac3b96d..779ede7a 100644 --- a/src/ui/tui.rs +++ b/src/ui/tui.rs @@ -39,7 +39,7 @@ pub struct DebuggerUI { } impl DebuggerUI { - pub fn new(engine: DebuggerEngine) -> Result { + pub fn new(engine: DebuggerEngine) -> crate::Result { Ok(Self { engine, config: crate::config::Config::load_or_default(), @@ -59,6 +59,16 @@ impl DebuggerUI { self.last_error = None; } + pub fn parse_storage_display_options(_parts: &[&str]) -> crate::Result { + // Basic implementation for now + Ok(StorageDisplayOptions { + filter: None, + jump_to: None, + page: 0, + page_size: 20, + }) + } + pub fn last_output(&self) -> Option<&str> { self.last_output.as_deref() } @@ -221,7 +231,7 @@ impl DebuggerUI { .map(|c| format!(" (if {:?})", c)) .unwrap_or_default(); crate::logging::log_display( - format!("- {}{}", bp.function, cond_str), + format!("- {}{} hits={}", bp.function, cond_str, bp.hit_count), crate::logging::LogLevel::Info, ); } @@ -500,7 +510,7 @@ impl DebuggerUI { crate::logging::LogLevel::Info, ); crate::logging::log_display( - " list-breaks List breakpoints", + " list-breaks List breakpoints with hit counts", crate::logging::LogLevel::Info, ); crate::logging::log_display( @@ -520,6 +530,14 @@ impl DebuggerUI { crate::logging::LogLevel::Info, ); } + + fn show_palette(&mut self) -> Result<()> { + crate::logging::log_display( + "Command palette not yet implemented in this view", + crate::logging::LogLevel::Warn, + ); + Ok(()) + } } ///////////////// diff --git a/tests/capability_negotiation_tests.rs b/tests/capability_negotiation_tests.rs new file mode 100644 index 00000000..6d8bc90e --- /dev/null +++ b/tests/capability_negotiation_tests.rs @@ -0,0 +1,259 @@ +//! Tests for Issue #837: Remote Capability Negotiation + +#[cfg(test)] +mod capability_negotiation { + use soroban_debugger::server::protocol::{ + DebugMessage, DebugRequest, DebugResponse, ServerCapabilities, PROTOCOL_MAX_VERSION, + PROTOCOL_MIN_VERSION, + }; + + #[test] + fn test_server_capabilities_current_build() { + let caps = ServerCapabilities::current(); + assert!(caps.conditional_breakpoints); + assert!(caps.source_breakpoints); + assert!(caps.evaluate); + assert!(caps.tls); + assert!(caps.token_auth); + assert!(caps.session_lifecycle); + assert!(caps.repeat_execution); + assert!(!caps.symbolic_analysis); + assert!(caps.snapshot_loading); + assert!(caps.dynamic_trace_events); + } + + #[test] + fn test_server_capabilities_default_is_empty() { + let caps = ServerCapabilities::default(); + assert!(!caps.conditional_breakpoints); + assert!(!caps.source_breakpoints); + assert!(!caps.evaluate); + assert!(!caps.tls); + assert!(!caps.token_auth); + assert!(!caps.session_lifecycle); + assert!(!caps.repeat_execution); + assert!(!caps.symbolic_analysis); + assert!(!caps.snapshot_loading); + assert!(!caps.dynamic_trace_events); + } + + #[test] + fn test_unsupported_by_identifies_missing_features() { + let client_required = ServerCapabilities { + evaluate: true, + snapshot_loading: true, + conditional_breakpoints: true, + ..Default::default() + }; + + let server_has = ServerCapabilities { + evaluate: true, + snapshot_loading: false, + conditional_breakpoints: true, + ..Default::default() + }; + + let missing = client_required.unsupported_by(&server_has); + assert_eq!(missing.len(), 1); + assert!(missing.contains(&"snapshot_loading")); + } + + #[test] + fn test_unsupported_by_returns_empty_when_all_supported() { + let client_required = ServerCapabilities { + evaluate: true, + conditional_breakpoints: true, + ..Default::default() + }; + + let server_has = ServerCapabilities::current(); + let missing = client_required.unsupported_by(&server_has); + assert!(missing.is_empty()); + } + + #[test] + fn test_handshake_request_with_required_capabilities() { + let required = ServerCapabilities { + evaluate: true, + snapshot_loading: true, + ..Default::default() + }; + + let request = DebugRequest::Handshake { + client_name: "test-client".to_string(), + client_version: "1.0.0".to_string(), + protocol_min: PROTOCOL_MIN_VERSION, + protocol_max: PROTOCOL_MAX_VERSION, + heartbeat_interval_ms: None, + idle_timeout_ms: None, + required_capabilities: Some(required.clone()), + }; + + let json = serde_json::to_string(&request).expect("Should serialize"); + assert!(json.contains("required_capabilities")); + + let deserialized: DebugRequest = + serde_json::from_str(&json).expect("Should deserialize"); + match deserialized { + DebugRequest::Handshake { + required_capabilities: Some(caps), + .. + } => { + assert!(caps.evaluate); + assert!(caps.snapshot_loading); + } + _ => panic!("Expected Handshake with required_capabilities"), + } + } + + #[test] + fn test_handshake_ack_includes_server_capabilities() { + let server_caps = ServerCapabilities::current(); + + let response = DebugResponse::HandshakeAck { + server_name: "soroban-debug".to_string(), + server_version: "1.0.0".to_string(), + protocol_min: PROTOCOL_MIN_VERSION, + protocol_max: PROTOCOL_MAX_VERSION, + selected_version: 1, + heartbeat_interval_ms: None, + idle_timeout_ms: None, + server_capabilities: server_caps.clone(), + }; + + let json = serde_json::to_string(&response).expect("Should serialize"); + assert!(json.contains("server_capabilities")); + + let deserialized: DebugResponse = + serde_json::from_str(&json).expect("Should deserialize"); + match deserialized { + DebugResponse::HandshakeAck { + server_capabilities: caps, + .. + } => { + assert_eq!(caps.evaluate, server_caps.evaluate); + assert_eq!(caps.snapshot_loading, server_caps.snapshot_loading); + } + _ => panic!("Expected HandshakeAck with server_capabilities"), + } + } + + #[test] + fn test_incompatible_capabilities_response() { + let server_caps = ServerCapabilities { + evaluate: true, + snapshot_loading: false, + ..Default::default() + }; + + let response = DebugResponse::IncompatibleCapabilities { + message: "Server does not support required capabilities: snapshot_loading" + .to_string(), + missing_capabilities: vec!["snapshot_loading".to_string()], + server_capabilities: server_caps.clone(), + }; + + let json = serde_json::to_string(&response).expect("Should serialize"); + assert!(json.contains("IncompatibleCapabilities")); + assert!(json.contains("missing_capabilities")); + + let deserialized: DebugResponse = + serde_json::from_str(&json).expect("Should deserialize"); + match deserialized { + DebugResponse::IncompatibleCapabilities { + missing_capabilities, + server_capabilities: caps, + .. + } => { + assert_eq!(missing_capabilities.len(), 1); + assert_eq!(missing_capabilities[0], "snapshot_loading"); + assert!(!caps.snapshot_loading); + } + _ => panic!("Expected IncompatibleCapabilities response"), + } + } + + #[test] + fn test_scenario_client_requires_feature_server_has_it() { + let client_required = ServerCapabilities { + evaluate: true, + snapshot_loading: true, + ..Default::default() + }; + + let server_has = ServerCapabilities::current(); + let missing = client_required.unsupported_by(&server_has); + assert!(missing.is_empty()); + } + + #[test] + fn test_scenario_client_requires_feature_server_lacks_it() { + let client_required = ServerCapabilities { + evaluate: true, + snapshot_loading: true, + symbolic_analysis: true, + ..Default::default() + }; + + let server_has = ServerCapabilities::current(); + let missing = client_required.unsupported_by(&server_has); + assert!(!missing.is_empty()); + assert!(missing.contains(&"symbolic_analysis")); + } + + #[test] + fn test_multiple_missing_capabilities_reported() { + let client_required = ServerCapabilities { + evaluate: true, + snapshot_loading: true, + symbolic_analysis: true, + dynamic_trace_events: true, + ..Default::default() + }; + + let server_has = ServerCapabilities { + evaluate: true, + snapshot_loading: false, + symbolic_analysis: false, + dynamic_trace_events: false, + ..Default::default() + }; + + let missing = client_required.unsupported_by(&server_has); + assert_eq!(missing.len(), 3); + assert!(missing.contains(&"snapshot_loading")); + assert!(missing.contains(&"symbolic_analysis")); + assert!(missing.contains(&"dynamic_trace_events")); + } + + #[test] + fn test_issue_837_acceptance_criteria() { + let client_required = ServerCapabilities { + snapshot_loading: true, + ..Default::default() + }; + + let server_has = ServerCapabilities { + snapshot_loading: false, + ..Default::default() + }; + + let missing = client_required.unsupported_by(&server_has); + assert!(!missing.is_empty()); + assert_eq!(missing.len(), 1); + assert_eq!(missing[0], "snapshot_loading"); + + let error_response = DebugResponse::IncompatibleCapabilities { + message: format!( + "Server does not support required capabilities: {}. Upgrade the server or disable these features on the client.", + missing.join(", ") + ), + missing_capabilities: missing.iter().map(|s| s.to_string()).collect(), + server_capabilities: server_has, + }; + + let json = serde_json::to_string(&error_response).expect("Should serialize"); + assert!(json.contains("IncompatibleCapabilities")); + assert!(json.contains("snapshot_loading")); + } +} diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index c1f2d6e7..8bc61006 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -13,3 +13,6 @@ pub mod network; mod output_tests; #[path = "cli/run_tests.rs"] mod run_tests; + +use crate::cli::common::setup_cli_test; +use fuel_cli::repl::commands::ReplCommand; \ No newline at end of file diff --git a/tests/interactive_mode_tests.rs b/tests/interactive_mode_tests.rs index 0dd5a762..7cdd0d69 100644 --- a/tests/interactive_mode_tests.rs +++ b/tests/interactive_mode_tests.rs @@ -45,3 +45,39 @@ fn interactive_accepts_basic_commands_and_exits() { String::from_utf8_lossy(&output.stderr) ); } + +#[test] +fn interactive_list_breaks_shows_hit_count() { + let wasm = fixture_wasm("counter"); + if !wasm.exists() { + eprintln!( + "Skipping test: fixture not found at {}. Run tests/fixtures/build.sh to build fixtures.", + wasm.display() + ); + return; + } + + let output = base_cmd() + .args([ + "interactive", + "--contract", + wasm.to_str().unwrap(), + "--function", + "get", + ]) + .write_stdin("break get\nlist-breaks\nquit\n") + .output() + .unwrap(); + + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + assert!(output.status.success(), "{combined}"); + assert!( + combined.contains("hits=0"), + "list-breaks should include hit count\n{combined}" + ); +} diff --git a/tests/plugin_tests.rs b/tests/plugin_tests.rs index 36c58af7..5b2d2e13 100644 --- a/tests/plugin_tests.rs +++ b/tests/plugin_tests.rs @@ -313,6 +313,7 @@ fn test_execution_events() { let event3 = ExecutionEvent::BreakpointHit { function: "test".to_string(), condition: Some("x > 10".to_string()), + hit_count: 1, }; let event4 = ExecutionEvent::Error {