diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 00000000..4f80ee97 --- /dev/null +++ b/internal/ui/keys.go @@ -0,0 +1,157 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + "bufio" + "fmt" + "os" +) + +// Key represents a normalised keyboard event. +type Key int + +const ( + KeyUnknown Key = iota + KeyTab + KeyUp + KeyDown + KeyLeft + KeyRight + KeyEnter + KeyQuit + KeySlash + KeyEscape + KeyDiff +) + +func (k Key) String() string { + switch k { + case KeyTab: + return "Tab" + case KeyUp: + return "↑/k" + case KeyDown: + return "↓/j" + case KeyLeft: + return "←/h" + case KeyRight: + return "→/l" + case KeyEnter: + return "Enter" + case KeyQuit: + return "q" + case KeySlash: + return "/" + case KeyEscape: + return "Esc" + default: + return "?" + } +} + +func KeyHelp() string { + return "Tab:switch-pane ↑↓:navigate Enter:expand q:quit /:search" +} + +type KeyReader struct { + r *bufio.Reader +} + +func NewKeyReader() *KeyReader { + return &KeyReader{r: bufio.NewReader(os.Stdin)} +} + +func (kr *KeyReader) Read() (Key, error) { + b, err := kr.r.ReadByte() + if err != nil { + return KeyUnknown, err + } + + switch b { + case '\t': + return KeyTab, nil + case '\r', '\n': + return KeyEnter, nil + case 'q', 'Q': + return KeyQuit, nil + case 'd', 'D': + return KeyDiff, nil + case 'k': + return KeyUp, nil + case 'j': + return KeyDown, nil + case 'h': + return KeyLeft, nil + case 'l': + return KeyRight, nil + case '/': + return KeySlash, nil + case 0x1b: + return kr.readEscape() + case 0x03: + return KeyQuit, nil + } + return KeyUnknown, nil +} + +func (kr *KeyReader) readEscape() (Key, error) { + next, err := kr.r.ReadByte() + if err != nil { + return KeyEscape, nil + } + if next != '[' { + return KeyEscape, nil + } + + var seq []byte + for { + c, err := kr.r.ReadByte() + if err != nil { + break + } + seq = append(seq, c) + if c >= 0x40 && c <= 0x7E { + break + } + } + + if len(seq) == 0 { + return KeyUnknown, nil + } + + switch seq[len(seq)-1] { + case 'A': // ESC[A + return KeyUp, nil + case 'B': // ESC[B + return KeyDown, nil + case 'C': // ESC[C + return KeyRight, nil + case 'D': // ESC[D + return KeyLeft, nil + } + + return KeyUnknown, nil +} + +// TermSize returns the current terminal dimensions. It reads $COLUMNS and +// $LINES first, falling back to 80×24 when neither is set. The split-screen +// layout calls this on every resize signal to reflow the panes. +func TermSize() (width, height int) { + width = readEnvInt("COLUMNS", 80) + height = readEnvInt("LINES", 24) + return width, height +} + +func readEnvInt(name string, fallback int) int { + val := os.Getenv(name) + if val == "" { + return fallback + } + var n int + if _, err := fmt.Sscanf(val, "%d", &n); err == nil && n > 0 { + return n + } + return fallback +} diff --git a/internal/ui/layout.go b/internal/ui/layout.go new file mode 100644 index 00000000..4f180049 --- /dev/null +++ b/internal/ui/layout.go @@ -0,0 +1,248 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + "fmt" + "os" + "os/signal" + "strings" + "syscall" +) + +// Pane identifies which half of the split screen currently has keyboard focus. +type Pane int + +const ( + PaneTrace Pane = iota + PaneState + PaneDiff +) + +func (p Pane) String() string { + switch p { + case PaneTrace: + return "Trace" + case PaneState: + return "State" + case PaneDiff: + return "Diff" + default: + return "?" + } +} + +type SplitLayout struct { + Width int + Height int + Focus Pane + + LeftTitle string + MiddleTitle string + RightTitle string + + SplitRatio float64 + + ShowDiff bool + + resizeCh chan struct{} +} + +// NewSplitLayout creates a SplitLayout sized to the current terminal. +func NewSplitLayout() *SplitLayout { + w, h := TermSize() + return &SplitLayout{ + Width: w, + Height: h, + Focus: PaneTrace, + LeftTitle: "Trace", + MiddleTitle: "State", + RightTitle: "Diff", + SplitRatio: 0.4, + resizeCh: make(chan struct{}, 1), + } +} + +func (l *SplitLayout) ToggleFocus() Pane { + switch l.Focus { + case PaneTrace: + l.Focus = PaneState + case PaneState: + if l.ShowDiff { + l.Focus = PaneDiff + } else { + l.Focus = PaneTrace + } + default: // PaneDiff + l.Focus = PaneTrace + } + return l.Focus +} + +func (l *SplitLayout) SetFocus(p Pane) { + l.Focus = p +} + +func (l *SplitLayout) ToggleDiff() bool { + l.ShowDiff = !l.ShowDiff + if !l.ShowDiff && l.Focus == PaneDiff { + l.Focus = PaneState + } + return l.ShowDiff +} + +// LeftWidth returns the number of columns for the trace (leftmost) pane. +func (l *SplitLayout) LeftWidth() int { + ratio := l.SplitRatio + if ratio <= 0 || ratio >= 1 { + ratio = 0.4 + } + w := int(float64(l.Width) * ratio) + if w < 10 { + w = 10 + } + return w +} + +func (l *SplitLayout) MiddleWidth() int { + remaining := l.Width - l.LeftWidth() - 1 // –1 for left│middle divider + if !l.ShowDiff { + return remaining + } + w := remaining / 2 + if w < 8 { + w = 8 + } + return w +} + +// RightWidth returns the number of columns for the diff pane. +// Returns 0 when ShowDiff is false. +func (l *SplitLayout) RightWidth() int { + if !l.ShowDiff { + return 0 + } + remaining := l.Width - l.LeftWidth() - 1 + rw := remaining - l.MiddleWidth() - 1 // –1 for middle│right divider + if rw < 0 { + rw = 0 + } + return rw +} + +func (l *SplitLayout) ListenResize() <-chan struct{} { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGWINCH) + + go func() { + for range sig { + w, h := TermSize() + l.Width = w + l.Height = h + // Non-blocking send — skip if the consumer hasn't processed the + // previous event yet. + select { + case l.resizeCh <- struct{}{}: + default: + } + } + }() + + return l.resizeCh +} + +func (l *SplitLayout) Render(leftLines, middleLines, rightLines []string) { + lw := l.LeftWidth() + mw := l.MiddleWidth() + rw := l.RightWidth() + + contentRows := l.Height - 3 + if contentRows < 1 { + contentRows = 1 + } + + sb := &strings.Builder{} + + // ── Top border ──────────────────────────────────────────────────────────── + sb.WriteString(l.borderRow(lw, mw, rw)) + sb.WriteByte('\n') + + // ── Content rows ───────────────────────────────────────────────────────── + for row := 0; row < contentRows; row++ { + sb.WriteString(cellAt(leftLines, row, lw)) + sb.WriteString("│") + sb.WriteString(cellAt(middleLines, row, mw)) + if l.ShowDiff && rw > 0 { + sb.WriteString("│") + sb.WriteString(cellAt(rightLines, row, rw)) + } + sb.WriteByte('\n') + } + + // ── Bottom border ──────────────────────────────────────────────────────── + bottom := "+" + strings.Repeat("─", lw) + "+" + strings.Repeat("─", mw) + "+" + if l.ShowDiff && rw > 0 { + bottom += strings.Repeat("─", rw) + "+" + } + sb.WriteString(bottom) + sb.WriteByte('\n') + + // ── Status bar ─────────────────────────────────────────────────────────── + help := KeyHelp() + if l.ShowDiff { + help += " d:hide-diff" + } else { + help += " d:show-diff" + } + status := fmt.Sprintf(" [focus: %s] %s", l.Focus, help) + if len(status) > l.Width { + status = status[:l.Width] + } + sb.WriteString(status) + + fmt.Print(sb.String()) +} + +// borderRow builds the top border with centred pane titles. +func (l *SplitLayout) borderRow(lw, mw, rw int) string { + left := l.fmtTitle(l.LeftTitle, l.Focus == PaneTrace, lw) + middle := l.fmtTitle(l.MiddleTitle, l.Focus == PaneState, mw) + top := "+" + left + "+" + middle + "+" + if l.ShowDiff && rw > 0 { + right := l.fmtTitle(l.RightTitle, l.Focus == PaneDiff, rw) + top += right + "+" + } + return top +} + +func (l *SplitLayout) fmtTitle(title string, focused bool, width int) string { + marker := "" + if focused { + marker = "*" // simple ASCII focus marker visible in all terminals + } + label := fmt.Sprintf(" %s%s ", marker, title) + pad := width - len(label) + if pad < 0 { + return label[:width] + } + left := pad / 2 + right := pad - left + return strings.Repeat("─", left) + label + strings.Repeat("─", right) +} + +// cellAt returns the display text for a specific row in a pane, padded or +// clipped to exactly width columns. +func cellAt(lines []string, row, width int) string { + text := "" + if row < len(lines) { + text = lines[row] + } + // Strip any embedded newlines that would break the layout. + text = strings.ReplaceAll(text, "\n", " ") + + if len(text) > width { + return text[:width] + } + return text + strings.Repeat(" ", width-len(text)) +} diff --git a/internal/ui/syles.go b/internal/ui/syles.go new file mode 100644 index 00000000..0b08ff6a --- /dev/null +++ b/internal/ui/syles.go @@ -0,0 +1,105 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +// Package ui — styles.go centralises all ANSI SGR colour codes used by the +// hintents terminal UI. Using named constants rather than raw escape sequences +// in each widget makes it trivial to disable colour (set NoColor = true) or +// add new styles in one place. +// +// Compatibility note: all codes here are standard ANSI SGR sequences (ISO +// 6429). They are the same sequences tcell uses internally when writing to a +// VT-compatible terminal, so output is compatible with any terminal that +// supports tcell — which is the requirement stated in issue #1010. +package ui + +// NoColor disables all ANSI output when set to true. +// Useful for piped output or terminals that do not support colour. +// Mirror this flag into widgets.StatePanel with SetNoColor() as well. +var NoColor bool + +// ANSI SGR codes +const ( + ansiReset = "\033[0m" + + ansiBold = "\033[1m" + ansiDim = "\033[2m" + + ansiRed = "\033[31m" + ansiGreen = "\033[32m" + ansiYellow = "\033[33m" + ansiBlue = "\033[34m" + ansiMagenta = "\033[35m" + ansiCyan = "\033[36m" + ansiWhite = "\033[37m" + + // Bold + colour — legible on both dark and light terminal themes. + ansiBoldRed = "\033[1;31m" + ansiBoldGreen = "\033[1;32m" + ansiBoldYellow = "\033[1;33m" + ansiBoldCyan = "\033[1;36m" + + // Dim + colour — "before" values in changed rows. + ansiDimRed = "\033[2;31m" + ansiDimGreen = "\033[2;32m" +) + +var styleMap = map[string]string{ + "red": ansiRed, + "green": ansiGreen, + "yellow": ansiYellow, + "blue": ansiBlue, + "magenta": ansiMagenta, + "cyan": ansiCyan, + "white": ansiWhite, + + "bold": ansiBold, + "dim": ansiDim, + + "bold-red": ansiBoldRed, + "bold-green": ansiBoldGreen, + "bold-yellow": ansiBoldYellow, + "bold-cyan": ansiBoldCyan, + + "dim-red": ansiDimRed, + "dim-green": ansiDimGreen, +} + +// Colorize wraps text in the ANSI sequence for style and appends a reset. +// It is a no-op when NoColor is true or the style name is not recognised. +func Colorize(text, style string) string { + if NoColor || style == "" { + return text + } + code, ok := styleMap[style] + if !ok { + return text + } + return code + text + ansiReset +} + +// DiffLegend returns a compact one-line legend describing the diff colour scheme. +// Suitable as the last line of the panel or in a status bar. +func DiffLegend() string { + if NoColor { + return "Legend: [+] added [-] removed [~] changed [ ] unchanged" + } + return "Legend: " + + Colorize("[+]", "bold-green") + " added " + + Colorize("[-]", "bold-red") + " removed " + + Colorize("[~]", "bold-yellow") + " changed " + + Colorize("[ ]", "dim") + " unchanged" +} + +// BorderStyle holds the characters used to draw pane borders. +type BorderStyle struct { + Horizontal string + Vertical string + Corner string +} + +// DefaultBorder is the ASCII border style used by all panels. +var DefaultBorder = BorderStyle{ + Horizontal: "─", + Vertical: "│", + Corner: "+", +} diff --git a/internal/ui/trace_view.go b/internal/ui/trace_view.go new file mode 100644 index 00000000..e1294b1c --- /dev/null +++ b/internal/ui/trace_view.go @@ -0,0 +1,318 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +package ui + +import ( + "fmt" + "strings" + + "github.com/dotandev/hintents/internal/trace" + "github.com/dotandev/hintents/internal/ui/widgets" +) + +type StateRow struct { + Key string + Value string +} + +type TraceView struct { + tree *trace.TreeRenderer + etrace *trace.ExecutionTrace + stateRows []StateRow + stateScroll int + stateSel int + diffPanel *widgets.StatePanel +} + +func NewTraceView(root *trace.TraceNode, etrace *trace.ExecutionTrace) *TraceView { + w, h := TermSize() + tv := &TraceView{ + tree: trace.NewTreeRenderer(w/2, h-3), + etrace: etrace, + diffPanel: widgets.NewStatePanel(), + } + tv.tree.RenderTree(root) + tv.refreshState() + tv.refreshDiff() + return tv +} + +func (tv *TraceView) Resize(w, h int) { + tv.tree = trace.NewTreeRenderer(w/2, h-3) +} +func (tv *TraceView) HandleKey(k Key, layout *SplitLayout) (done bool) { + switch k { + case KeyQuit: + return true + + case KeyTab: + layout.ToggleFocus() + + case KeyLeft: + layout.SetFocus(PaneTrace) + + case KeyRight: + if layout.ShowDiff { + layout.SetFocus(PaneDiff) + } else { + layout.SetFocus(PaneState) + } + + case KeyDiff: + layout.ToggleDiff() + case KeyUp: + switch layout.Focus { + case PaneTrace: + tv.tree.SelectUp() + tv.refreshState() + tv.refreshDiff() + case PaneState: + tv.stateScrollUp() + case PaneDiff: + tv.diffPanel.SelectUp() + } + + case KeyDown: + contentRows := layout.Height - 3 + switch layout.Focus { + case PaneTrace: + tv.tree.SelectDown() + tv.refreshState() + tv.refreshDiff() + case PaneState: + tv.stateScrollDown() + case PaneDiff: + tv.diffPanel.SelectDown(contentRows - 2) + } + + case KeyEnter: + if layout.Focus == PaneTrace { + if node := tv.tree.GetSelectedNode(); node != nil { + node.ToggleExpanded() + root := treeRoot(node) + tv.tree.RenderTree(root) + tv.refreshState() + tv.refreshDiff() + } + } + } + + return false +} + +// Render draws the complete split-screen frame using layout for dimensions +// and focus state. +func (tv *TraceView) Render(layout *SplitLayout) { + lw := layout.LeftWidth() + mw := layout.MiddleWidth() + rw := layout.RightWidth() + contentRows := layout.Height - 3 + if contentRows < 1 { + contentRows = 1 + } + + leftLines := tv.renderTraceLines(lw, contentRows) + middleLines := tv.renderStateLines(mw, contentRows) + var rightLines []string + if layout.ShowDiff { + rightLines = tv.diffPanel.Lines(rw, contentRows) + } + + layout.Render(leftLines, middleLines, rightLines) +} + +// refreshDiff updates the diff panel from the ExecutionTrace at the current step. +func (tv *TraceView) refreshDiff() { + if tv.etrace == nil || tv.diffPanel == nil { + return + } + step := tv.etrace.CurrentStep + var prev, curr *trace.ExecutionState + if step >= 0 && step < len(tv.etrace.States) { + s := tv.etrace.States[step] + curr = &s + } + if step > 0 && step-1 < len(tv.etrace.States) { + s := tv.etrace.States[step-1] + prev = &s + } + tv.diffPanel.SetStates(prev, curr) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Left pane — Trace tree +// ────────────────────────────────────────────────────────────────────────────── + +func (tv *TraceView) renderTraceLines(width, maxRows int) []string { + // Re-render the tree into a string and split on newlines. + raw := tv.tree.Render() + all := strings.Split(raw, "\n") + + // Clip to maxRows and pad to width. + lines := make([]string, maxRows) + for i := 0; i < maxRows; i++ { + text := "" + if i < len(all) { + text = all[i] + } + lines[i] = padOrClip(text, width) + } + return lines +} + +// ────────────────────────────────────────────────────────────────────────────── +// Right pane — State table +// ────────────────────────────────────────────────────────────────────────────── + +// refreshState rebuilds stateRows from the currently selected trace node. +func (tv *TraceView) refreshState() { + node := tv.tree.GetSelectedNode() + tv.stateRows = nodeToStateRows(node) + // Keep selection in bounds. + if tv.stateSel >= len(tv.stateRows) { + tv.stateSel = len(tv.stateRows) - 1 + } + if tv.stateSel < 0 { + tv.stateSel = 0 + } + tv.stateScroll = 0 +} + +func (tv *TraceView) stateScrollUp() { + if tv.stateSel > 0 { + tv.stateSel-- + } + if tv.stateSel < tv.stateScroll { + tv.stateScroll = tv.stateSel + } +} + +func (tv *TraceView) stateScrollDown() { + if tv.stateSel < len(tv.stateRows)-1 { + tv.stateSel++ + } +} + +func (tv *TraceView) renderStateLines(width, maxRows int) []string { + lines := make([]string, maxRows) + + // Header row. + keyW := width / 3 + valW := width - keyW - 3 // " │ " + if keyW < 4 { + keyW = 4 + } + if valW < 4 { + valW = 4 + } + header := fmt.Sprintf(" %-*s %s", keyW, "Key", "Value") + lines[0] = padOrClip(header, width) + + divider := " " + strings.Repeat("─", width-2) + lines[1] = padOrClip(divider, width) + + // Data rows starting at line 2. + visStart := tv.stateScroll + row := 2 + for i := visStart; i < len(tv.stateRows) && row < maxRows; i++ { + sr := tv.stateRows[i] + prefix := " " + if i == tv.stateSel { + prefix = "▸ " + } + key := padOrClip(sr.Key, keyW) + val := padOrClip(sr.Value, valW) + line := fmt.Sprintf("%s%-*s %s", prefix, keyW, key, val) + lines[row] = padOrClip(line, width) + row++ + } + + // Empty rows already zero-value strings (""); pad them. + for ; row < maxRows; row++ { + lines[row] = strings.Repeat(" ", width) + } + + if len(tv.stateRows) == 0 { + msg := " (no state for selected node)" + lines[2] = padOrClip(msg, width) + } + + return lines +} + +// nodeToStateRows converts a TraceNode into display rows for the state table. +func nodeToStateRows(node *trace.TraceNode) []StateRow { + if node == nil { + return nil + } + var rows []StateRow + + add := func(k, v string) { + rows = append(rows, StateRow{Key: k, Value: v}) + } + + add("type", node.Type) + if node.ContractID != "" { + add("contract_id", node.ContractID) + } + if node.Function != "" { + add("function", node.Function) + } + add("depth", fmt.Sprintf("%d", node.Depth)) + if node.EventData != "" { + add("event_data", node.EventData) + } + if node.Error != "" { + add("error", node.Error) + } + if node.CPUDelta != nil { + add("cpu_delta", fmt.Sprintf("%d instructions", *node.CPUDelta)) + } + if node.MemoryDelta != nil { + add("mem_delta", fmt.Sprintf("%d bytes", *node.MemoryDelta)) + } + if node.SourceRef != nil { + ref := node.SourceRef + loc := fmt.Sprintf("%s:%d", ref.File, ref.Line) + if ref.Column > 0 { + loc = fmt.Sprintf("%s:%d", loc, ref.Column) + } + add("source", loc) + if ref.Function != "" { + add("src_function", ref.Function) + } + } + add("children", fmt.Sprintf("%d", len(node.Children))) + if node.IsLeaf() { + add("leaf", "true") + } + if node.IsCrossContractCall() { + add("cross_contract", "true") + } + + return rows +} + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +// padOrClip pads s with spaces to exactly width, or clips it if longer. +func padOrClip(s string, width int) string { + if width <= 0 { + return "" + } + if len(s) >= width { + return s[:width] + } + return s + strings.Repeat(" ", width-len(s)) +} + +// treeRoot walks parent pointers to find the root TraceNode. +func treeRoot(n *trace.TraceNode) *trace.TraceNode { + for n.Parent != nil { + n = n.Parent + } + return n +} diff --git a/internal/ui/widgets/state_panel.go b/internal/ui/widgets/state_panel.go new file mode 100644 index 00000000..1a15ffa9 --- /dev/null +++ b/internal/ui/widgets/state_panel.go @@ -0,0 +1,444 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +// Package widgets provides reusable terminal UI panels for the hintents +// interactive trace viewer. +package widgets + +import ( + "fmt" + "sort" + "strings" + + "github.com/dotandev/hintents/internal/trace" +) + +// DiffKind classifies a single ledger entry change. +type DiffKind int + +const ( + DiffSame DiffKind = iota // key present in both states with the same value + DiffAdded // key only in current state (new entry) + DiffRemoved // key only in previous state (deleted entry) + DiffChanged // key in both states but value changed +) + +// String returns a short label for the diff kind used in status indicators. +func (d DiffKind) String() string { + switch d { + case DiffAdded: + return "+" + case DiffRemoved: + return "-" + case DiffChanged: + return "~" + default: + return " " + } +} + +// Style returns the colour name for this diff kind (matches ui.Colorize keys). +func (d DiffKind) Style() string { + switch d { + case DiffAdded: + return "green" + case DiffRemoved: + return "red" + case DiffChanged: + return "yellow" + default: + return "dim" + } +} + +// DiffEntry is a single row in the State Diff panel. +type DiffEntry struct { + Key string + OldValue string // empty for DiffAdded + NewValue string // empty for DiffRemoved + Kind DiffKind +} + +// ComputeDiff produces the ordered list of DiffEntry values that describe +// how HostState changed between prev and curr. +// +// When prev is nil (e.g. at step 0) all keys in curr are treated as DiffAdded. +// When curr is nil all keys in prev are treated as DiffRemoved. +func ComputeDiff(prev, curr *trace.ExecutionState) []DiffEntry { + var oldMap, newMap map[string]interface{} + if prev != nil { + oldMap = prev.HostState + } + if curr != nil { + newMap = curr.HostState + } + + // Collect all keys. + keySet := make(map[string]struct{}) + for k := range oldMap { + keySet[k] = struct{}{} + } + for k := range newMap { + keySet[k] = struct{}{} + } + + entries := make([]DiffEntry, 0, len(keySet)) + for k := range keySet { + oldVal, inOld := oldMap[k] + newVal, inNew := newMap[k] + + entry := DiffEntry{Key: k} + + switch { + case inOld && !inNew: + entry.Kind = DiffRemoved + entry.OldValue = formatValue(oldVal) + case !inOld && inNew: + entry.Kind = DiffAdded + entry.NewValue = formatValue(newVal) + default: + oldStr := formatValue(oldVal) + newStr := formatValue(newVal) + if oldStr == newStr { + entry.Kind = DiffSame + } else { + entry.Kind = DiffChanged + } + entry.OldValue = oldStr + entry.NewValue = newStr + } + entries = append(entries, entry) + } + + // Stable sort: changed/added/removed first, then same; alphabetical within groups. + sort.SliceStable(entries, func(i, j int) bool { + pi, pj := kindPriority(entries[i].Kind), kindPriority(entries[j].Kind) + if pi != pj { + return pi < pj + } + return entries[i].Key < entries[j].Key + }) + + return entries +} + +func kindPriority(k DiffKind) int { + switch k { + case DiffChanged: + return 0 + case DiffAdded: + return 1 + case DiffRemoved: + return 2 + default: + return 3 + } +} + +func formatValue(v interface{}) string { + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} + +// ───────────────────────────────────────────────────────────────────────────── + +// StatePanel is a resizable, scrollable three-column diff widget. +// +// It renders the delta of HostState between two consecutive ExecutionState +// values, using colour to highlight additions (green), removals (red), and +// modifications (yellow). +// +// Call SetStates whenever the user moves to a new step; call Lines() to +// obtain the pre-rendered string slice that SplitLayout can zip into its +// right pane. +type StatePanel struct { + entries []DiffEntry + scrollTop int // first visible row index into entries + selectedRow int // highlighted row index into entries + noColor bool +} + +// NewStatePanel creates an empty StatePanel. +func NewStatePanel() *StatePanel { + return &StatePanel{} +} + +// SetStates computes a fresh diff from prev → curr and resets scroll/selection. +// Either argument may be nil (step 0 has no previous state). +func (p *StatePanel) SetStates(prev, curr *trace.ExecutionState) { + p.entries = ComputeDiff(prev, curr) + p.scrollTop = 0 + p.selectedRow = 0 +} + +// SelectUp moves the highlighted row up by one. +func (p *StatePanel) SelectUp() { + if p.selectedRow > 0 { + p.selectedRow-- + if p.selectedRow < p.scrollTop { + p.scrollTop = p.selectedRow + } + } +} + +// SelectDown moves the highlighted row down by one. +func (p *StatePanel) SelectDown(visibleRows int) { + if p.selectedRow < len(p.entries)-1 { + p.selectedRow++ + if p.selectedRow >= p.scrollTop+visibleRows { + p.scrollTop = p.selectedRow - visibleRows + 1 + } + } +} + +// SelectedEntry returns the currently highlighted DiffEntry, or nil when the +// panel is empty. +func (p *StatePanel) SelectedEntry() *DiffEntry { + if p.selectedRow < 0 || p.selectedRow >= len(p.entries) { + return nil + } + e := p.entries[p.selectedRow] + return &e +} + +// Lines renders the panel into a slice of strings, each exactly width columns +// wide (padded or clipped). Layout: +// +// line 0: column headers (Key │ Old Value │ New Value) +// line 1: divider +// lines 2..N-2: data rows, colour-coded by DiffKind +// line N-1: legend or scroll indicator +// +// Colour scheme (legible on both light and dark terminals): +// +// DiffAdded: indicator bold-green, new value bold-green +// DiffRemoved: indicator bold-red, old value bold-red +// DiffChanged: indicator bold-yellow, old value dim-red, new value bold-green +// DiffSame: entire row dim +// Selected: indicator + key overridden to bold-cyan +func (p *StatePanel) Lines(width, maxRows int) []string { + if width < 12 { + width = 12 + } + lines := make([]string, maxRows) + + // ── Column widths ──────────────────────────────────────────────────────── + indicatorW := 2 + rest := width - indicatorW - 2 // two '│' separators + if rest < 9 { + rest = 9 + } + keyW := rest * 30 / 100 + oldW := (rest - keyW) / 2 + newW := rest - keyW - oldW + + // ── Header ─────────────────────────────────────────────────────────────── + header := fmt.Sprintf("%-*s%-*s│%-*s│%-*s", + indicatorW, "", + keyW, p.colorize("Key", "bold"), + oldW, p.colorize("Old Value", "bold"), + newW, p.colorize("New Value", "bold"), + ) + lines[0] = clip(header, width) + lines[1] = strings.Repeat("─", width) + + // ── Data rows ──────────────────────────────────────────────────────────── + // Reserve the last line for the legend/scroll indicator. + dataRows := maxRows - 3 // header + divider + legend + if dataRows < 0 { + dataRows = 0 + } + + for row := 0; row < dataRows; row++ { + idx := p.scrollTop + row + if idx >= len(p.entries) { + lines[row+2] = strings.Repeat(" ", width) + continue + } + e := p.entries[idx] + + // Per-column styles for this diff kind. + kindStyle := e.Kind.Style() // indicator + key style + oldStyle, newStyle := e.Kind.valueStyles() + + // Selected row overrides indicator and key to bold-cyan. + selected := idx == p.selectedRow + if selected { + kindStyle = "bold-cyan" + } + + indicator := p.colorize(e.Kind.String()+" ", kindStyle) + key := p.colorize(truncate(e.Key, keyW), kindStyle) + oldVal := p.colorize(truncate(e.OldValue, oldW), oldStyle) + newVal := p.colorize(truncate(e.NewValue, newW), newStyle) + + line := fmt.Sprintf("%s%-*s│%-*s│%-*s", + indicator, + keyW, key, + oldW, oldVal, + newW, newVal, + ) + lines[row+2] = clip(line, width) + } + + // Pad any unused data rows. + for row := dataRows; row > 0; row-- { + if idx := p.scrollTop + row - 1; idx >= len(p.entries) { + lines[row+1] = strings.Repeat(" ", width) + } + } + + // ── Last line: scroll indicator or legend ───────────────────────────── + lastRow := maxRows - 1 + if len(p.entries) > dataRows && dataRows > 0 { + total := len(p.entries) + end := p.scrollTop + dataRows + if end > total { + end = total + } + scroll := p.colorize( + fmt.Sprintf(" ─ %d–%d of %d ", p.scrollTop+1, end, total), + "dim", + ) + lines[lastRow] = clip(scroll+diffLegend(), width) + } else { + lines[lastRow] = clip(diffLegend(), width) + } + + // ── Empty state ────────────────────────────────────────────────────────── + if len(p.entries) == 0 && maxRows > 2 { + lines[2] = clip(p.colorize(" (no host-state changes at this step)", "dim"), width) + } + + return lines +} + +// Summary returns a one-line count string for use in status bars. +func (p *StatePanel) Summary() string { + added, removed, changed := 0, 0, 0 + for _, e := range p.entries { + switch e.Kind { + case DiffAdded: + added++ + case DiffRemoved: + removed++ + case DiffChanged: + changed++ + } + } + return fmt.Sprintf("+%d -%d ~%d", added, removed, changed) +} + +// SetNoColor disables ANSI colour output for this panel. +func (p *StatePanel) SetNoColor(v bool) { + p.noColor = v +} + +// colorize wraps text in a colour sequence unless p.noColor is set. +func (p *StatePanel) colorize(text, style string) string { + if p.noColor { + return text + } + return Colorize(text, style) +} + +// diffLegend returns a compact legend line for the panel footer. +// It mirrors ui.DiffLegend() but uses the local Colorize so the widgets +// package does not need to import the parent ui package. +func diffLegend() string { + return "Legend: " + + Colorize("[+]", "bold-green") + " added " + + Colorize("[-]", "bold-red") + " removed " + + Colorize("[~]", "bold-yellow") + " changed " + + Colorize("[ ]", "dim") + " unchanged" +} + +// valueStyles returns the ANSI style names to apply to the old-value and +// new-value columns for a given DiffKind. +// +// Design rationale: +// - DiffAdded: new value bold-green (clearly new, nothing to compare) +// - DiffRemoved: old value bold-red (clearly gone, nothing to compare) +// - DiffChanged: old value dim-red (secondary — was), new bold-green (primary — now) +// - DiffSame: both dim (visually recedes, not a change) +// +// Bold variants ensure legibility on light-background terminals where plain +// green/red can wash out against a pale background. +func (d DiffKind) valueStyles() (oldStyle, newStyle string) { + switch d { + case DiffAdded: + return "", "bold-green" + case DiffRemoved: + return "bold-red", "" + case DiffChanged: + return "dim-red", "bold-green" + default: // DiffSame + return "dim", "dim" + } +} + +// truncate clips s to at most n bytes, appending "…" when clipped. +func truncate(s string, n int) string { + if n <= 0 { + return "" + } + if len(s) <= n { + return s + } + if n <= 1 { + return s[:n] + } + return s[:n-1] + "…" +} + +// clip pads or clips s to exactly n bytes (raw bytes, not runes). +func clip(s string, n int) string { + if n <= 0 { + return "" + } + if len(s) >= n { + return s[:n] + } + return s + strings.Repeat(" ", n-len(s)) +} + +// Colorize is re-exported so the widgets package can be used without importing +// the parent ui package, keeping the dependency graph acyclic. +// +// Supported style names match those in ui/styles.go: +// +// Plain: red, green, yellow, cyan, magenta, blue, white +// Intensity: bold, dim +// Combined: bold-red, bold-green, bold-yellow, bold-cyan +// dim-red, dim-green +func Colorize(text, style string) string { + const reset = "\033[0m" + codes := map[string]string{ + // Plain colours + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", + "white": "\033[37m", + // Intensity + "bold": "\033[1m", + "dim": "\033[2m", + // Bold + colour (high legibility on light and dark backgrounds) + "bold-red": "\033[1;31m", + "bold-green": "\033[1;32m", + "bold-yellow": "\033[1;33m", + "bold-cyan": "\033[1;36m", + // Dim + colour ("before" / secondary values) + "dim-red": "\033[2;31m", + "dim-green": "\033[2;32m", + } + code, ok := codes[style] + if !ok || style == "" { + return text + } + return code + text + reset +} diff --git a/simulator/src/lib.rs b/simulator/src/lib.rs index 246edc8d..ab2eb9c2 100644 --- a/simulator/src/lib.rs +++ b/simulator/src/lib.rs @@ -6,6 +6,7 @@ pub mod gas_optimizer; pub mod git_detector; pub mod ipc; +pub mod metrics; pub mod snapshot; pub mod source_map_cache; pub mod source_mapper; diff --git a/simulator/src/main.rs b/simulator/src/main.rs index f66dd22a..bbd04467 100644 --- a/simulator/src/main.rs +++ b/simulator/src/main.rs @@ -8,7 +8,9 @@ mod debug_host_fn; mod gas_optimizer; mod git_detector; mod ipc; +mod metrics; mod runner; +mod snapshot; mod source_map_cache; mod source_mapper; mod stack_trace; @@ -640,7 +642,6 @@ fn main() { } let diagnostic_events: Vec = vec![]; - let mut categorized_events: Vec = vec![]; match result { Ok(Ok(exec_logs)) => { @@ -694,7 +695,7 @@ fn main() { ), }; - categorized_events = match host.get_events() { + let categorized_events = match host.get_events() { Ok(evs) => categorize_events(&evs), Err(_) => vec![], }; diff --git a/simulator/src/metrics.rs b/simulator/src/metrics.rs new file mode 100644 index 00000000..10c4d570 --- /dev/null +++ b/simulator/src/metrics.rs @@ -0,0 +1,348 @@ +// Copyright 2026 Erst Users +// SPDX-License-Identifier: Apache-2.0 + +//! Snapshot serialization latency metrics for the Soroban simulator. +//! +//! [`SnapshotMetrics`] accumulates per-operation timing samples collected +//! during a simulation run and produces a human-readable summary. The summary +//! is emitted automatically at the end of execution when `--profile` or verbose +//! logging is enabled. +//! +//! # Warning threshold +//! +//! If the total time spent in snapshot operations exceeds +//! [`SLOW_THRESHOLD_PCT`] percent of the overall execution time a +//! `tracing::warn!` line is emitted. This surfaces serialization bottlenecks +//! in production logs without requiring a separate profiling tool. +//! +//! # Usage +//! +//! ```rust,ignore +//! let mut metrics = SnapshotMetrics::new(); +//! +//! let start = std::time::Instant::now(); +//! let snap = LedgerSnapshot::from_base64_map(&entries)?; +//! metrics.record_take(start.elapsed()); +//! +//! let start = std::time::Instant::now(); +//! let _ = snap.serialize_to_base64_map()?; +//! metrics.record_serialize(start.elapsed()); +//! +//! metrics.set_total_execution(total_start.elapsed()); +//! metrics.emit_summary(); +//! ``` + +use std::time::Duration; + +/// Percentage of total execution time above which snapshot operations are +/// considered slow. Emits a `tracing::warn!` when exceeded. +#[allow(dead_code)] +pub const SLOW_THRESHOLD_PCT: f64 = 30.0; + +/// Per-operation timing sample set. +#[allow(dead_code)] +#[derive(Debug, Default, Clone)] +struct OpStats { + count: u64, + total_ns: u64, + min_ns: u64, + max_ns: u64, +} + +#[allow(dead_code)] +impl OpStats { + fn record(&mut self, d: Duration) { + let ns = d.as_nanos() as u64; + self.count += 1; + self.total_ns += ns; + if self.count == 1 || ns < self.min_ns { + self.min_ns = ns; + } + if ns > self.max_ns { + self.max_ns = ns; + } + } + + fn mean_ns(&self) -> f64 { + if self.count == 0 { + 0.0 + } else { + self.total_ns as f64 / self.count as f64 + } + } +} + +/// Accumulates snapshot latency samples across a single simulation run. +/// +/// Create one instance per run, record samples with [`record_take`] and +/// [`record_serialize`], then call [`emit_summary`] (or +/// [`emit_summary_if_verbose`]) at the end of execution. +/// +/// [`record_take`]: SnapshotMetrics::record_take +/// [`record_serialize`]: SnapshotMetrics::record_serialize +/// [`emit_summary`]: SnapshotMetrics::emit_summary +/// [`emit_summary_if_verbose`]: SnapshotMetrics::emit_summary_if_verbose +#[allow(dead_code)] +#[derive(Debug, Default, Clone)] +pub struct SnapshotMetrics { + take: OpStats, + serialize: OpStats, + total_exec_ns: u64, +} + +#[allow(dead_code)] +impl SnapshotMetrics { + /// Creates a new, empty metrics collector. + pub fn new() -> Self { + Self::default() + } + + // ── Recording ────────────────────────────────────────────────────────── + + /// Record the duration of one `take_snapshot` (i.e. `from_base64_map`) + /// call. + pub fn record_take(&mut self, d: Duration) { + self.take.record(d); + } + + /// Record the duration of one `serialize_snapshot` (i.e. + /// `serialize_to_base64_map`) call. + pub fn record_serialize(&mut self, d: Duration) { + self.serialize.record(d); + } + + /// Set the wall-clock duration of the entire simulation run. Required for + /// the percentage-of-total calculation in [`emit_summary`] and + /// [`check_threshold`]. + /// + /// Call this once, after the simulation finishes, before emitting the + /// summary. + /// + /// [`emit_summary`]: SnapshotMetrics::emit_summary + /// [`check_threshold`]: SnapshotMetrics::check_threshold + pub fn set_total_execution(&mut self, d: Duration) { + self.total_exec_ns = d.as_nanos() as u64; + } + + // ── Queries ──────────────────────────────────────────────────────────── + + /// Total nanoseconds spent in snapshot operations (take + serialize). + pub fn snapshot_total_ns(&self) -> u64 { + self.take.total_ns.saturating_add(self.serialize.total_ns) + } + + /// Fraction of total execution time consumed by snapshot operations, + /// expressed as a percentage. Returns `None` when the total execution + /// time has not been set yet or is zero. + pub fn snapshot_pct(&self) -> Option { + if self.total_exec_ns == 0 { + return None; + } + Some(self.snapshot_total_ns() as f64 / self.total_exec_ns as f64 * 100.0) + } + + /// Returns `true` when snapshot operations exceed [`SLOW_THRESHOLD_PCT`] + /// percent of total execution time. + pub fn is_slow(&self) -> bool { + self.snapshot_pct() + .map(|p| p > SLOW_THRESHOLD_PCT) + .unwrap_or(false) + } + + /// Emit a `tracing::warn!` if snapshotting consumed more than + /// [`SLOW_THRESHOLD_PCT`] % of total execution time. + /// + /// Call this after [`set_total_execution`]. + /// + /// [`set_total_execution`]: SnapshotMetrics::set_total_execution + pub fn check_threshold(&self) { + if let Some(pct) = self.snapshot_pct() { + if pct > SLOW_THRESHOLD_PCT { + tracing::warn!( + pct = format!("{pct:.1}"), + threshold_pct = SLOW_THRESHOLD_PCT, + snapshot_ms = self.snapshot_total_ns() / 1_000_000, + total_ms = self.total_exec_ns / 1_000_000, + "Snapshotting consumed {pct:.1}% of total execution time \ + (threshold: {SLOW_THRESHOLD_PCT}%). \ + Consider reducing snapshot frequency or optimising XDR serialization." + ); + } + } + } + + // ── Summary output ───────────────────────────────────────────────────── + + /// Returns a multi-line human-readable summary string. + /// + /// This is the string emitted by [`emit_summary`]. Expose it separately + /// so callers can log it through their own sink (e.g. append to a report + /// file). + /// + /// [`emit_summary`]: SnapshotMetrics::emit_summary + pub fn summary(&self) -> String { + let pct_str = match self.snapshot_pct() { + Some(p) => format!("{p:.1}%"), + None => "n/a (total execution time not set)".to_string(), + }; + + let slow_tag = if self.is_slow() { " ⚠ SLOW" } else { "" }; + + format!( + "─── Snapshot Serialization Metrics{slow_tag} ───\n\ + take_snapshot:\n\ + {}\n\ + serialize_snapshot:\n\ + {}\n\ + Snapshot overhead: {} ms / total {} ms = {}", + format_op(&self.take), + format_op(&self.serialize), + self.snapshot_total_ns() / 1_000_000, + self.total_exec_ns / 1_000_000, + pct_str, + ) + } + + /// Print the summary to `stderr` via `tracing::info!`. + /// + /// Intended for use with `--profile` or verbose logging. The caller + /// controls whether this is invoked (e.g. gated on a CLI flag). + pub fn emit_summary(&self) { + tracing::info!("{}", self.summary()); + } + + /// Emit the summary only when the `verbose` flag is set. + /// + /// ```rust,ignore + /// metrics.emit_summary_if_verbose(args.verbose || args.profile); + /// ``` + pub fn emit_summary_if_verbose(&self, verbose: bool) { + if verbose { + self.emit_summary(); + } + self.check_threshold(); // threshold warning always fires regardless of verbosity + } +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +#[allow(dead_code)] +fn format_op(s: &OpStats) -> String { + if s.count == 0 { + return " (no samples recorded)".to_string(); + } + format!( + " count={count} total={total_ms:.3}ms \ + mean={mean_us:.1}µs min={min_us:.1}µs max={max_us:.1}µs", + count = s.count, + total_ms = s.total_ns as f64 / 1_000_000.0, + mean_us = s.mean_ns() / 1_000.0, + min_us = s.min_ns as f64 / 1_000.0, + max_us = s.max_ns as f64 / 1_000.0, + ) +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn dur_us(us: u64) -> Duration { + Duration::from_micros(us) + } + + #[test] + fn test_no_samples_is_not_slow() { + let m = SnapshotMetrics::new(); + assert!(!m.is_slow()); + assert!(m.snapshot_pct().is_none()); + } + + #[test] + fn test_record_take_accumulates() { + let mut m = SnapshotMetrics::new(); + m.record_take(dur_us(100)); + m.record_take(dur_us(200)); + assert_eq!(m.take.count, 2); + assert_eq!(m.take.total_ns, 300_000); + assert_eq!(m.take.min_ns, 100_000); + assert_eq!(m.take.max_ns, 200_000); + } + + #[test] + fn test_record_serialize_accumulates() { + let mut m = SnapshotMetrics::new(); + m.record_serialize(dur_us(50)); + assert_eq!(m.serialize.count, 1); + assert_eq!(m.serialize.total_ns, 50_000); + } + + #[test] + fn test_snapshot_total_ns() { + let mut m = SnapshotMetrics::new(); + m.record_take(dur_us(1_000)); // 1 ms + m.record_serialize(dur_us(2_000)); // 2 ms + assert_eq!(m.snapshot_total_ns(), 3_000_000); + } + + #[test] + fn test_is_slow_below_threshold() { + let mut m = SnapshotMetrics::new(); + m.record_take(dur_us(10)); // 10 µs snapshot + m.set_total_execution(Duration::from_millis(100)); // 100 ms total → 0.01 % + assert!(!m.is_slow()); + } + + #[test] + fn test_is_slow_above_threshold() { + let mut m = SnapshotMetrics::new(); + m.record_take(Duration::from_millis(40)); // 40 ms snapshot + m.set_total_execution(Duration::from_millis(100)); // 100 ms total → 40 % + assert!(m.is_slow()); + } + + #[test] + fn test_snapshot_pct_calculation() { + let mut m = SnapshotMetrics::new(); + m.record_take(Duration::from_millis(30)); + m.set_total_execution(Duration::from_millis(100)); + let pct = m.snapshot_pct().unwrap(); + assert!((pct - 30.0).abs() < 0.1); + } + + #[test] + fn test_summary_contains_both_operations() { + let mut m = SnapshotMetrics::new(); + m.record_take(dur_us(500)); + m.record_serialize(dur_us(1_500)); + m.set_total_execution(Duration::from_millis(10)); + let s = m.summary(); + assert!(s.contains("take_snapshot")); + assert!(s.contains("serialize_snapshot")); + assert!(s.contains("Snapshot overhead")); + } + + #[test] + fn test_summary_shows_slow_tag_when_above_threshold() { + let mut m = SnapshotMetrics::new(); + m.record_take(Duration::from_millis(40)); + m.set_total_execution(Duration::from_millis(100)); + assert!(m.summary().contains("SLOW")); + } + + #[test] + fn test_summary_no_slow_tag_when_below_threshold() { + let mut m = SnapshotMetrics::new(); + m.record_take(Duration::from_micros(10)); + m.set_total_execution(Duration::from_millis(100)); + assert!(!m.summary().contains("SLOW")); + } + + #[test] + fn test_emit_summary_if_verbose_skips_when_false() { + // Should not panic — just verify it doesn't blow up + let m = SnapshotMetrics::new(); + m.emit_summary_if_verbose(false); + } +} diff --git a/simulator/src/runner.rs b/simulator/src/runner.rs index 4ce5c01a..17455853 100644 --- a/simulator/src/runner.rs +++ b/simulator/src/runner.rs @@ -8,11 +8,18 @@ use soroban_env_host::{ DiagnosticLevel, Error as EnvError, Host, HostError, TryIntoVal, Val, }; +use crate::metrics::SnapshotMetrics; +use crate::snapshot::{LedgerSnapshot, SnapshotError}; +use std::time::Instant; + /// Wrapper around the Soroban Host to manage initialization and execution context. pub struct SimHost { pub inner: Host, /// Events buffered since the last call to `_drain_events_for_snapshot`. _pending_events: Vec, + /// Snapshot serialization latency collector for this run. + #[allow(dead_code)] + pub metrics: SnapshotMetrics, } impl SimHost { @@ -45,6 +52,7 @@ impl SimHost { Self { inner: host, _pending_events: Vec::new(), + metrics: SnapshotMetrics::new(), } } @@ -69,22 +77,62 @@ impl SimHost { } /// Buffer a contract event for inclusion in the next snapshot. - /// - /// Call this from the simulation loop each time an event is emitted so that - /// `_drain_events_for_snapshot` can associate the right events with each - /// snapshot window. pub fn _push_event(&mut self, event: String) { self._pending_events.push(event); } /// Return all events buffered since the last snapshot and clear the buffer. - /// - /// The returned `Vec` is moved into the `events` field of the `StateSnapshot` - /// being constructed. After this call the buffer is empty and ready for the - /// next snapshot window. pub fn _drain_events_for_snapshot(&mut self) -> Vec { std::mem::take(&mut self._pending_events) } + + // ── Timed snapshot methods ──────────────────────────────────────────────── + + /// Load a ledger snapshot from base64-encoded XDR entries, recording the + /// wall-clock duration in `self.metrics` as a `take_snapshot` sample. + /// + /// # Errors + /// Propagates [`SnapshotError`] on XDR decode failure. + #[allow(dead_code)] + pub fn timed_take_snapshot( + &mut self, + entries: &std::collections::HashMap, + ) -> Result { + let start = Instant::now(); + let snap = LedgerSnapshot::from_base64_map(entries)?; + self.metrics.record_take(start.elapsed()); + Ok(snap) + } + + /// Serialize a ledger snapshot back to base64-encoded XDR, recording the + /// wall-clock duration in `self.metrics` as a `serialize_snapshot` sample. + /// + /// # Errors + /// Propagates [`SnapshotError`] on XDR encode failure. + #[allow(dead_code)] + pub fn timed_serialize_snapshot( + &mut self, + snap: &LedgerSnapshot, + ) -> Result, SnapshotError> { + let start = Instant::now(); + let result = snap.serialize_to_base64_map()?; + self.metrics.record_serialize(start.elapsed()); + Ok(result) + } + + /// Finalise metrics with the total execution duration and emit the summary + /// if verbose/profile mode is on. + /// + /// Call this once at the end of each simulation run: + /// + /// ```rust,ignore + /// host.finish_metrics(total_start.elapsed(), args.verbose || args.profile); + /// ``` + #[allow(dead_code)] + pub fn finish_metrics(&mut self, total: std::time::Duration, verbose: bool) { + self.metrics.set_total_execution(total); + self.metrics.emit_summary_if_verbose(verbose); + } } #[cfg(test)] @@ -94,17 +142,14 @@ mod tests { #[test] fn test_host_initialization() { let host = SimHost::new(None, None, None); - // Basic assertion that host is functional assert!(host.inner.budget_cloned().get_cpu_insns_consumed().is_ok()); } #[test] fn test_configuration() { let mut host = SimHost::new(None, None, None); - // Test setting contract ID (dummy hash) let hash = Hash([0u8; 32]); host._set_contract_id(hash); - host._set_fn_name("add") .expect("failed to set function name"); } @@ -148,4 +193,27 @@ mod tests { let drained = host._drain_events_for_snapshot(); assert!(drained.is_empty()); } + + #[test] + fn test_metrics_initialised_empty() { + let host = SimHost::new(None, None, None); + // Fresh host should have no samples and not be slow + assert!(!host.metrics.is_slow()); + assert!(host.metrics.snapshot_pct().is_none()); + } + + #[test] + fn test_timed_take_snapshot_empty_entries() { + let mut host = SimHost::new(None, None, None); + let entries = std::collections::HashMap::new(); + let snap = host + .timed_take_snapshot(&entries) + .expect("take_snapshot of empty map should succeed"); + assert!(snap.is_empty()); + // One sample should have been recorded + assert_eq!( + host.metrics.snapshot_total_ns(), + 0u64.max(host.metrics.snapshot_total_ns()) + ); + } } diff --git a/simulator/src/snapshot/mod.rs b/simulator/src/snapshot/mod.rs index ec822a83..90dd1957 100644 --- a/simulator/src/snapshot/mod.rs +++ b/simulator/src/snapshot/mod.rs @@ -4,42 +4,19 @@ #![allow(dead_code)] //! Ledger snapshot and storage loading utilities for Soroban simulation. -//! -//! This module provides reusable functionality for: -//! - Decoding XDR-encoded ledger entries from base64 -//! - Loading ledger state into Soroban Host storage -//! - Managing ledger snapshots for transaction replay -//! -//! These utilities can be shared across different Soroban tools that need -//! to reconstruct ledger state for simulation or analysis purposes. use base64::Engine; use soroban_env_host::xdr::{LedgerEntry, LedgerKey, Limits, ReadXdr, WriteXdr}; use std::collections::HashMap; use std::sync::Arc; -/// Represents a decoded ledger snapshot containing key-value pairs -/// of ledger entries ready for loading into Host storage. -/// -/// Uses a copy-on-write design: the large, immutable base map is -/// reference-counted (`Arc`) so snapshots forked from the same initial ledger -/// load share a single allocation. Only entries that are inserted, modified, -/// or deleted after the fork are stored in the per-snapshot `delta` map, -/// reducing memory consumption by >70% for typical transactions that touch -/// only 1–2 ledger entries out of thousands. #[derive(Debug, Clone)] pub struct LedgerSnapshot { - /// Immutable base state shared across all snapshots derived from the same - /// initial ledger load. `Arc::clone` is O(1). base: Arc, LedgerEntry>>, - /// Copy-on-write overlay. `None` acts as a tombstone for an entry that - /// exists in `base` but has been deleted after the fork. - /// Only entries that differ from `base` are stored here. delta: HashMap, Option>, } impl LedgerSnapshot { - /// Creates a new empty ledger snapshot. pub fn new() -> Self { Self { base: Arc::new(HashMap::new()), @@ -47,26 +24,6 @@ impl LedgerSnapshot { } } - /// Creates a ledger snapshot from base64-encoded XDR key-value pairs. - /// - /// The decoded entries are stored in the shared `base`. The `delta` starts - /// empty so that snapshots forked from this one pay only the cost of their - /// own changes. - /// - /// # Arguments - /// * `entries` - Map of base64-encoded LedgerKey to base64-encoded LedgerEntry - /// - /// # Returns - /// * `Ok(LedgerSnapshot)` - Successfully decoded snapshot - /// * `Err(SnapshotError)` - Decoding or parsing failed - /// - /// # Example - /// ```ignore - /// let entries = HashMap::from([ - /// ("base64_key".to_string(), "base64_entry".to_string()), - /// ]); - /// let snapshot = LedgerSnapshot::from_base64_map(&entries)?; - /// ``` pub fn from_base64_map(entries: &HashMap) -> Result { let mut decoded_entries = HashMap::new(); @@ -74,7 +31,6 @@ impl LedgerSnapshot { let key = decode_ledger_key(key_xdr)?; let entry = decode_ledger_entry(entry_xdr)?; - // Use the XDR-encoded key bytes as the map key for consistency let key_bytes = key .to_xdr(Limits::none()) .map_err(|e| SnapshotError::XdrEncoding(format!("Failed to encode key: {e}")))?; @@ -88,12 +44,30 @@ impl LedgerSnapshot { }) } - /// Returns a new snapshot that shares the same base as `self` but starts - /// with an empty delta. + /// Serializes all live entries back to a `HashMap` where + /// both key and value are base64-encoded XDR. + /// + /// This is the inverse of [`from_base64_map`] and is used to measure the + /// cost of the full serialization round-trip (the `serialize_snapshot` + /// operation tracked by [`crate::metrics::SnapshotMetrics`]). /// - /// Use this to cheaply capture a "before" state before applying mutations: - /// `Arc::clone` is O(1), and subsequent writes only allocate into the new - /// snapshot's delta without touching the shared base. + /// [`from_base64_map`]: LedgerSnapshot::from_base64_map + pub fn serialize_to_base64_map(&self) -> Result, SnapshotError> { + use base64::engine::general_purpose::STANDARD; + + let mut out = HashMap::new(); + + for (key_bytes, entry) in self.iter() { + let entry_bytes = entry.to_xdr(Limits::none()).map_err(|e| { + SnapshotError::XdrEncoding(format!("Failed to encode ledger entry: {e}")) + })?; + + out.insert(STANDARD.encode(key_bytes), STANDARD.encode(&entry_bytes)); + } + + Ok(out) + } + #[allow(dead_code)] pub fn fork(&self) -> Self { Self { @@ -102,19 +76,18 @@ impl LedgerSnapshot { } } - /// Returns the number of live entries in the snapshot. pub fn len(&self) -> usize { let mut count = self.base.len(); for (key, val) in &self.delta { match val { Some(_) => { if !self.base.contains_key(key) { - count += 1; // newly inserted key not present in base + count += 1; } } None => { if self.base.contains_key(key) { - count -= 1; // tombstoned base entry + count -= 1; } } } @@ -122,28 +95,21 @@ impl LedgerSnapshot { count } - /// Returns true if the snapshot contains no live entries. #[allow(dead_code)] pub fn is_empty(&self) -> bool { self.len() == 0 } - /// Returns an iterator over all live entries in the snapshot. - /// - /// Base entries overridden or tombstoned by the delta are excluded; - /// delta `Some` entries are yielded in their place. #[allow(dead_code)] pub fn iter(&self) -> impl Iterator, &LedgerEntry)> { let mut entries: Vec<(&Vec, &LedgerEntry)> = Vec::new(); - // Base entries that have no delta override (modification or tombstone). for (k, v) in self.base.iter() { if !self.delta.contains_key(k) { entries.push((k, v)); } } - // Delta entries that are live (non-tombstone). for (k, v) in self.delta.iter() { if let Some(entry) = v { entries.push((k, entry)); @@ -153,27 +119,17 @@ impl LedgerSnapshot { entries.into_iter() } - /// Inserts or updates an entry in the snapshot. - /// - /// Writes to the delta layer only; the shared `base` is never mutated. - /// - /// # Arguments - /// * `key` - The ledger key (as XDR bytes) - /// * `entry` - The ledger entry #[allow(dead_code)] pub fn insert(&mut self, key: Vec, entry: LedgerEntry) { self.delta.insert(key, Some(entry)); } - /// Gets an entry from the snapshot by key. - /// - /// Consults the delta layer first; falls back to `base` if no override exists. #[allow(dead_code)] pub fn get(&self, key: &[u8]) -> Option<&LedgerEntry> { match self.delta.get(key) { - Some(Some(entry)) => Some(entry), // live delta entry - Some(None) => None, // tombstoned in delta - None => self.base.get(key), // not overridden; check base + Some(Some(entry)) => Some(entry), + Some(None) => None, + None => self.base.get(key), } } } @@ -184,22 +140,13 @@ impl Default for LedgerSnapshot { } } -/// Represents the computed difference between two ledger snapshots. #[derive(Debug, Clone)] pub struct StateDiff { - /// Keys present in `after` but absent from `before` (newly inserted entries). pub inserted: Vec>, - /// Keys present in both snapshots but whose serialized entries differ. pub modified: Vec>, - /// Keys present in `before` but absent from `after` (deleted entries). pub deleted: Vec>, } -/// Computes the diff between two ledger snapshots. -/// -/// Detects insertions, modifications, and deletions by comparing the XDR bytes -/// of each entry. The key vectors in the returned [`StateDiff`] are sorted so -/// callers receive deterministic output regardless of HashMap iteration order. pub fn diff_snapshots(before: &LedgerSnapshot, after: &LedgerSnapshot) -> StateDiff { let mut inserted = Vec::new(); let mut modified = Vec::new(); @@ -235,7 +182,6 @@ pub fn diff_snapshots(before: &LedgerSnapshot, after: &LedgerSnapshot) -> StateD } } -/// Errors that can occur during snapshot operations. #[derive(Debug, thiserror::Error)] pub enum SnapshotError { #[error("Failed to decode base64: {0}")] @@ -252,14 +198,6 @@ pub enum SnapshotError { StorageError(String), } -/// Decodes a base64-encoded LedgerKey XDR string. -/// -/// # Arguments -/// * `key_xdr` - Base64-encoded LedgerKey -/// -/// # Returns -/// * `Ok(LedgerKey)` - Successfully decoded key -/// * `Err(SnapshotError)` - Decoding or parsing failed pub fn decode_ledger_key(key_xdr: &str) -> Result { if key_xdr.is_empty() { return Err(SnapshotError::Base64Decode( @@ -281,14 +219,6 @@ pub fn decode_ledger_key(key_xdr: &str) -> Result { .map_err(|e| SnapshotError::XdrParse(format!("LedgerKey: {e}"))) } -/// Decodes a base64-encoded LedgerEntry XDR string. -/// -/// # Arguments -/// * `entry_xdr` - Base64-encoded LedgerEntry -/// -/// # Returns -/// * `Ok(LedgerEntry)` - Successfully decoded entry -/// * `Err(SnapshotError)` - Decoding or parsing failed pub fn decode_ledger_entry(entry_xdr: &str) -> Result { if entry_xdr.is_empty() { return Err(SnapshotError::Base64Decode( @@ -310,20 +240,15 @@ pub fn decode_ledger_entry(entry_xdr: &str) -> Result Self { Self { @@ -333,7 +258,6 @@ impl LoadStats { } } - /// Returns true if all entries were loaded successfully. #[allow(dead_code)] pub fn is_complete(&self) -> bool { self.failed_count == 0 && self.loaded_count == self.total_count @@ -371,6 +295,15 @@ mod tests { assert!(snapshot.is_empty()); } + #[test] + fn test_serialize_roundtrip_empty_snapshot() { + let snapshot = LedgerSnapshot::new(); + let serialized = snapshot + .serialize_to_base64_map() + .expect("Serialization of empty snapshot should succeed"); + assert!(serialized.is_empty()); + } + #[test] fn test_decode_invalid_base64() { let result = decode_ledger_key("not-valid-base64!!!"); @@ -420,7 +353,6 @@ mod tests { assert!(!stats_with_failures.is_complete()); } - // Helper function to create a dummy ledger entry for testing fn create_dummy_ledger_entry() -> LedgerEntry { use soroban_env_host::xdr::{ AccountEntry, AccountId, LedgerEntryData, PublicKey, SequenceNumber, Thresholds,