mirror of
https://github.com/aykhans/sarin.git
synced 2026-08-29 03:24:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
623c1c4d97 | ||
|
|
f5eaf76fc4 | ||
|
|
7ec63a2a43 | ||
|
|
492c3f1449 | ||
|
|
d03d34576e
|
||
|
|
8beff615bb | ||
|
|
a85c82e751
|
||
|
|
ad5cfadaf2 | ||
|
|
8078b2c1fe | ||
|
|
3c4f449c97
|
||
|
|
deba048e3c | ||
|
|
77e8bc1eb8 | ||
|
|
a81d95ef6d
|
||
|
|
8195b92547 | ||
|
|
f42c4c70bf | ||
|
|
f3f25e3ba1
|
||
|
|
8121d75a2c
|
||
|
|
3867140704 | ||
|
|
740b6f6bbb
|
||
|
|
88180ae288
|
||
|
|
0314ea4f3e
|
||
|
|
9602595216
|
||
|
|
c195ca2ab2
|
||
|
|
e13eb9b8a8
|
||
|
|
d68c557dd3
|
||
|
|
d08b6dc222
|
||
|
|
8552aab3b1
|
||
|
|
79143beec9 | ||
|
|
bb1ed1c2ce | ||
|
|
a3434b096a | ||
|
|
e83e9346a3
|
||
|
|
01b5233167
|
||
|
|
e93b729145 | ||
|
|
372d228bb1
|
||
|
|
52a6e78e0c | ||
|
|
b22e3a0d25
|
||
|
|
4b52556b30
|
||
|
|
755545d838
|
||
|
|
dc2cd6cf63 | ||
|
|
838d65d83f |
@@ -0,0 +1,172 @@
|
||||
name: nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Ref to check out. Defaults to the triggering ref."
|
||||
type: string
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# Keyed on the PR author, which is stable across the fix-up push, so the run
|
||||
# started by that push cannot cancel the run that made it.
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]' }}
|
||||
|
||||
env:
|
||||
SELF_HOSTED_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
IS_DEPENDABOT: ${{ github.actor == 'dependabot[bot]' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# The only write is git push, which authenticates with the PAT that
|
||||
# checkout persists, not with GITHUB_TOKEN.
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# head.sha, not the merge ref: the hash is computed for the tree that
|
||||
# gets pushed, and the run that verifies it must build that same tree.
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.ref }}
|
||||
token: ${{ (env.IS_DEPENDABOT == 'true' && secrets.DEPENDABOT) || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
github_access_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: nix build
|
||||
id: build
|
||||
continue-on-error: ${{ env.SELF_HOSTED_PR == 'true' }}
|
||||
run: nix build .#default --no-link --print-build-logs
|
||||
|
||||
- name: Recompute vendorHash
|
||||
id: hash
|
||||
if: steps.build.outcome == 'failure' && env.SELF_HOSTED_PR == 'true'
|
||||
run: |
|
||||
nix run nixpkgs#nix-update -- --flake --version=skip --no-src default
|
||||
|
||||
# An unchanged hash means the build broke for some other reason. Say
|
||||
# nothing and let the job fail on the real error.
|
||||
if git diff --quiet -- nix/package.nix; then
|
||||
echo "stale=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
sed -n 's/^[[:space:]]*vendorHash = "\([^"]*\)".*/hash=\1/p' nix/package.nix >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Push vendorHash fix
|
||||
if: steps.hash.outputs.stale == 'true' && env.IS_DEPENDABOT == 'true'
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
PUSH_TOKEN: ${{ secrets.DEPENDABOT }}
|
||||
run: |
|
||||
if [ -z "$PUSH_TOKEN" ]; then
|
||||
echo "::error::the DEPENDABOT push token is missing from the Dependabot secret store"
|
||||
exit 1
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add nix/package.nix
|
||||
git commit -m "fix(nix): update vendorHash [dependabot skip]"
|
||||
git push origin "HEAD:refs/heads/$HEAD_REF"
|
||||
echo "::notice::vendorHash updated and pushed; the run on the new commit decides this PR"
|
||||
|
||||
- name: Comment stale vendorHash
|
||||
if: steps.hash.outputs.stale == 'true' && env.IS_DEPENDABOT != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
HASH: ${{ steps.hash.outputs.hash }}
|
||||
run: |
|
||||
marker='<!-- vendorhash-bot -->'
|
||||
body=$(cat <<EOF
|
||||
$marker
|
||||
### \`nix/package.nix\` vendorHash is stale
|
||||
|
||||
This branch changes the Go dependencies, so the vendor derivation no longer matches the pinned hash and \`nix build\` fails.
|
||||
|
||||
\`\`\`nix
|
||||
vendorHash = "$HASH";
|
||||
\`\`\`
|
||||
|
||||
Run \`task nix-hash\` locally and commit the result, or paste the hash above.
|
||||
EOF
|
||||
)
|
||||
|
||||
# Keep one sticky comment instead of a new one on every push. Without
|
||||
# the explicit check a failed lookup reads as "no comment yet" and
|
||||
# posts a duplicate; piping into head would hide the exit status.
|
||||
if ! matches=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments?per_page=100" \
|
||||
--jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body // \"\") | contains(\"$marker\"))) | .id"); then
|
||||
echo "::error::could not list the pull request comments"
|
||||
exit 1
|
||||
fi
|
||||
id=$(printf '%s\n' "$matches" | head -n 1)
|
||||
if [ -n "$id" ]; then
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
|
||||
else
|
||||
gh pr comment "$PR" --body "$body"
|
||||
fi
|
||||
|
||||
# Once the hash is fixed the build passes and the step above stops running,
|
||||
# so without this the comment keeps claiming the branch is broken under a
|
||||
# green check.
|
||||
- name: Resolve stale vendorHash comment
|
||||
# Gated on the hash rather than the build: a later push can fix the hash
|
||||
# while the build still fails for an unrelated reason, and the comment
|
||||
# must stop blaming the vendor derivation either way. Skipped when the
|
||||
# recompute itself errored, since then we do not know.
|
||||
if: ${{ !cancelled() && env.SELF_HOSTED_PR == 'true' && steps.hash.outputs.stale != 'true' && steps.hash.outcome != 'failure' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
BUILD: ${{ steps.build.outcome }}
|
||||
run: |
|
||||
marker='<!-- vendorhash-bot -->'
|
||||
if ! matches=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR/comments?per_page=100" \
|
||||
--jq ".[] | select(.user.login == \"github-actions[bot]\" and ((.body // \"\") | contains(\"$marker\"))) | .id"); then
|
||||
echo "::error::could not list the pull request comments"
|
||||
exit 1
|
||||
fi
|
||||
id=$(printf '%s\n' "$matches" | head -n 1)
|
||||
[ -n "$id" ] || exit 0
|
||||
|
||||
if [ "$BUILD" = "success" ]; then
|
||||
detail='`nix build` passes on this branch.'
|
||||
else
|
||||
detail='The build is failing for another reason; see the workflow log.'
|
||||
fi
|
||||
body=$(cat <<EOF
|
||||
$marker
|
||||
### \`nix/package.nix\` vendorHash is up to date
|
||||
|
||||
$detail
|
||||
EOF
|
||||
)
|
||||
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -f body="$body" >/dev/null
|
||||
|
||||
- name: Report build failure
|
||||
if: ${{ !cancelled() && steps.build.outcome == 'failure' && !(env.IS_DEPENDABOT == 'true' && steps.hash.outputs.stale == 'true') }}
|
||||
run: |
|
||||
echo "::error file=nix/package.nix::nix build failed; see the log above"
|
||||
exit 1
|
||||
@@ -21,8 +21,47 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
name: Verify Nix package
|
||||
uses: ./.github/workflows/nix.yaml
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
# nix/package.nix carries its own version string that has to be bumped by
|
||||
# hand; catch a forgotten bump before the tag ships.
|
||||
version:
|
||||
name: Verify package version
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.tag || github.ref }}
|
||||
|
||||
- name: Check nix/package.nix matches the release tag
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
expected="${TAG#v}"
|
||||
actual="$(sed -n 's/^[[:space:]]*version = "\([^"]*\)".*/\1/p' nix/package.nix | head -1)"
|
||||
if [ -z "$actual" ]; then
|
||||
echo "::error file=nix/package.nix::could not read the version string"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo "::error file=nix/package.nix::version is '$actual' but the release tag is '$TAG' (expected '$expected')"
|
||||
exit 1
|
||||
fi
|
||||
echo "nix/package.nix version '$actual' matches tag '$TAG'"
|
||||
|
||||
build:
|
||||
name: Build binaries
|
||||
needs: [nix, version]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
@@ -81,6 +81,11 @@ linters:
|
||||
- staticcheck
|
||||
text: "SA5011"
|
||||
|
||||
- linters:
|
||||
- errcheck
|
||||
- gosec
|
||||
source: "lipgloss\\.Println\\("
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gofmt
|
||||
|
||||
@@ -26,13 +26,11 @@ Sarin is designed for efficient HTTP load testing with minimal resource consumpt
|
||||
|
||||
| ✅ Supported | ❌ Not Supported |
|
||||
| ---------------------------------------------------------- | ------------------------------- |
|
||||
| High-performance with low memory footprint | Detailed response body analysis |
|
||||
| Long-running duration/count based tests | Extensive response statistics |
|
||||
| Dynamic requests via 340+ template functions | Web UI or complex TUI |
|
||||
| High-performance with low memory footprint | Web UI or complex TUI |
|
||||
| Dynamic requests via 340+ template functions | Detailed response body analysis |
|
||||
| Request scripting with Lua and JavaScript | Distributed load testing |
|
||||
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
||||
| Captcha solving<br>(2Captcha, Anti-Captcha, CapSolver) | Plugins / extensions ecosystem |
|
||||
| Flexible config (CLI, ENV, YAML) | |
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -40,7 +38,7 @@ Sarin is designed for efficient HTTP load testing with minimal resource consumpt
|
||||
<summary><b>Docker</b></summary>
|
||||
|
||||
```sh
|
||||
docker run -it aykhans/sarin:latest --version
|
||||
docker run --rm -it aykhans/sarin:latest --version
|
||||
```
|
||||
|
||||
With a local config file:
|
||||
|
||||
@@ -39,6 +39,11 @@ tasks:
|
||||
cmds:
|
||||
- "{{.GOLANGCI}} run"
|
||||
|
||||
nix-hash:
|
||||
desc: Recompute nix/package.nix vendorHash after a dependency change.
|
||||
cmds:
|
||||
- nix run nixpkgs#nix-update -- --flake --version=skip --no-src default
|
||||
|
||||
test:
|
||||
desc: Run Go tests.
|
||||
cmds:
|
||||
|
||||
+8
-7
@@ -7,6 +7,7 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"go.aykhans.me/sarin/internal/config"
|
||||
"go.aykhans.me/sarin/internal/sarin"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
@@ -32,13 +33,13 @@ func main() {
|
||||
utilsErr.OnType(func(err types.FieldValidationErrors) error {
|
||||
for _, fieldErr := range err.Errors {
|
||||
if fieldErr.Value == "" {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s': ", fieldErr.Field))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
config.StyleYellow.Render(fmt.Sprintf("[VALIDATION] Field '%s' (%s): ", fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
os.Exit(1)
|
||||
@@ -58,17 +59,17 @@ func main() {
|
||||
)
|
||||
_ = utilsErr.MustHandle(err,
|
||||
utilsErr.OnType(func(err types.ProxyDialError) error {
|
||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[PROXY] ")+err.Error())
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[PROXY] ")+err.Error()))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
|
||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error()))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
utilsErr.OnType(func(err types.ScriptLoadError) error {
|
||||
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(config.StyleRed.Render("[SCRIPT] ")+err.Error()))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
|
||||
@@ -3,56 +3,51 @@ module go.aykhans.me/sarin
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/brianvoe/gofakeit/v7 v7.15.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/glamour v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||
charm.land/bubbles/v2 v2.2.1
|
||||
charm.land/bubbletea/v2 v2.0.9
|
||||
charm.land/glamour/v2 v2.0.1
|
||||
charm.land/lipgloss/v2 v2.0.6
|
||||
github.com/brianvoe/gofakeit/v7 v7.16.0
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347
|
||||
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/valyala/fasthttp v1.72.0
|
||||
github.com/valyala/fasthttp v1.73.0
|
||||
github.com/yuin/gopher-lua v1.1.2
|
||||
go.aykhans.me/utils v1.0.7
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/net v0.58.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.2 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.7 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260726004341-482a56510f1b // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.7.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/klauspost/compress v1.19.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.27 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/yuin/goldmark v1.8.4 // indirect
|
||||
github.com/xo/terminfo v1.0.0 // indirect
|
||||
github.com/yuin/goldmark v1.8.5 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/glamour/v2 v2.0.1 h1:xl+r00A4aJWU0z8fgwKd9fQQ4rsphqGUzuEiXZP5n+c=
|
||||
charm.land/glamour/v2 v2.0.1/go.mod h1:jo9z8XqVKPeEFMVdvCRLGk++RyJ3CdUwgNr7EvXLw3k=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
@@ -8,99 +16,78 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
|
||||
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/brianvoe/gofakeit/v7 v7.15.0 h1:kGLYAWN8tnmxq2PelKVK6zwpM7kMxdz9SGPH31mFkNs=
|
||||
github.com/brianvoe/gofakeit/v7 v7.15.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/brianvoe/gofakeit/v7 v7.16.0 h1:LXNcvT4Klw72/hqpLNNdEWFIcP7G0VFPNsqvEIGONBE=
|
||||
github.com/brianvoe/gofakeit/v7 v7.16.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
||||
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260726004341-482a56510f1b h1:VKASlRztdxT172siF4nJCwD1C+3mbveX41BiA96fIpA=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260726004341-482a56510f1b/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504 h1:Z0hBPQ9hslsfpFRRdMn+4cjnb3LK6FQH58hQkXrXv0A=
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260816001655-68d539dca504/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
|
||||
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347 h1:RZr+96+PKQjn444QL1K9MtncwJ/PwfE+3TJLCYJL8es=
|
||||
github.com/dop251/goja v0.0.0-20260723142020-b4aef50fa347/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/dlclark/regexp2/v2 v2.7.1 h1:yqDtwI1ptXXvEUNpYTk2lad4jLtAcKqkzepn4savSk4=
|
||||
github.com/dlclark/regexp2/v2 v2.7.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6 h1:Oh2rRG1un7tLlC3/NJDzKppZ4CeZGkVFJCUOTRwLpfw=
|
||||
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
|
||||
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
|
||||
github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s=
|
||||
github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k=
|
||||
github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY=
|
||||
github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
|
||||
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||
@@ -111,14 +98,13 @@ go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+40
-33
@@ -12,12 +12,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/glamour/styles"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/term"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/glamour/v2"
|
||||
"charm.land/glamour/v2/styles"
|
||||
"charm.land/lipgloss/v2"
|
||||
"go.aykhans.me/sarin/internal/sarin"
|
||||
"go.aykhans.me/sarin/internal/script"
|
||||
"go.aykhans.me/sarin/internal/types"
|
||||
@@ -249,12 +248,12 @@ func (config Config) MarshalYAML() (any, error) {
|
||||
func (config Config) Print() bool {
|
||||
configYAML, err := yaml.Marshal(config)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render("Error marshaling config to yaml: "+err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render("Error marshaling config to yaml: "+err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Pipe mode: output raw content directly
|
||||
if !term.IsTerminal(os.Stdout.Fd()) {
|
||||
if !sarin.IsInteractiveTerminal(os.Stdout.Fd()) {
|
||||
fmt.Println(string(configYAML))
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -268,25 +267,23 @@ func (config Config) Print() bool {
|
||||
glamour.WithWordWrap(0),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
content, err := renderer.Render("```yaml\n" + string(configYAML) + "```")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(
|
||||
printConfigModel{content: strings.Trim(content, "\n"), rawContent: configYAML},
|
||||
tea.WithAltScreen(),
|
||||
tea.WithMouseCellMotion(),
|
||||
)
|
||||
|
||||
m, err := p.Run()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, StyleRed.Render(err.Error()))
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(StyleRed.Render(err.Error())))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -616,11 +613,11 @@ func ReadAllConfigs() *Config {
|
||||
_ = utilsErr.MustHandle(err,
|
||||
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
|
||||
cliParser.PrintHelp()
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(
|
||||
"\nUnexpected CLI arguments provided: ",
|
||||
)+strings.Join(err.Args, ", "),
|
||||
)
|
||||
))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
@@ -638,20 +635,20 @@ func ReadAllConfigs() *Config {
|
||||
_ = utilsErr.MustHandle(err,
|
||||
utilsErr.OnType(func(err types.ConfigFileReadError) error {
|
||||
cliParser.PrintHelp()
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(
|
||||
fmt.Sprintf("\nFailed to read config file (%s): ", configFile.Path())+err.Error(),
|
||||
),
|
||||
)
|
||||
))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
utilsErr.OnType(func(err types.UnmarshalError) error {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(
|
||||
fmt.Sprintf("\nFailed to parse config file (%s): ", configFile.Path())+err.Error(),
|
||||
),
|
||||
)
|
||||
))
|
||||
os.Exit(1)
|
||||
return nil
|
||||
}),
|
||||
@@ -754,13 +751,13 @@ func validateScriptSource(script string) error {
|
||||
func printParseErrors(parserName string, errors ...types.FieldParseError) {
|
||||
for _, fieldErr := range errors {
|
||||
if fieldErr.Value == "" {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s': ", parserName, fieldErr.Field))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr,
|
||||
fmt.Fprint(os.Stderr, lipgloss.Sprintln(
|
||||
StyleYellow.Render(fmt.Sprintf("[%s] Field '%s' (%s): ", parserName, fieldErr.Field, fieldErr.Value))+fieldErr.Err.Error(),
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -801,7 +798,7 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
@@ -824,13 +821,22 @@ func (m printConfigModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m printConfigModel) View() string {
|
||||
func (m printConfigModel) View() tea.View {
|
||||
// AltScreen and MouseMode were program options in bubbletea v1; in v2 they
|
||||
// are view properties, so every return path has to declare them.
|
||||
newView := func(s string) tea.View {
|
||||
v := tea.NewView(s)
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
return v
|
||||
}
|
||||
|
||||
if !m.ready {
|
||||
return "\n Initializing..."
|
||||
return newView("\n Initializing...")
|
||||
}
|
||||
|
||||
content := lipgloss.JoinHorizontal(lipgloss.Top, m.viewport.View(), m.scrollbar())
|
||||
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView())
|
||||
return newView(fmt.Sprintf("%s\n%s\n%s", m.headerView(), content, m.footerView()))
|
||||
}
|
||||
|
||||
func (m *printConfigModel) saveContent() (printConfigModel, tea.Cmd) {
|
||||
@@ -850,12 +856,13 @@ func (m *printConfigModel) handleResize(msg tea.WindowSizeMsg) {
|
||||
width := msg.Width - scrollbarWidth
|
||||
|
||||
if !m.ready {
|
||||
m.viewport = viewport.New(width, height)
|
||||
m.viewport = viewport.New(viewport.WithWidth(width), viewport.WithHeight(height))
|
||||
m.viewport.SetHorizontalStep(0)
|
||||
m.viewport.SetContent(m.contentWithLineNumbers())
|
||||
m.ready = true
|
||||
} else {
|
||||
m.viewport.Width = width
|
||||
m.viewport.Height = height
|
||||
m.viewport.SetWidth(width)
|
||||
m.viewport.SetHeight(height)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -870,12 +877,12 @@ func (m printConfigModel) headerView() string {
|
||||
printConfigKeyStyle.Render("ESC") + printConfigDescStyle.Render(" exit")
|
||||
title = printConfigHelpStyle.Render(help)
|
||||
}
|
||||
line := strings.Repeat("─", max(0, m.viewport.Width+scrollbarWidth-lipgloss.Width(title)))
|
||||
line := strings.Repeat("─", max(0, m.viewport.Width()+scrollbarWidth-lipgloss.Width(title)))
|
||||
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
|
||||
}
|
||||
|
||||
func (m printConfigModel) footerView() string {
|
||||
return strings.Repeat("─", m.viewport.Width+scrollbarWidth)
|
||||
return strings.Repeat("─", m.viewport.Width()+scrollbarWidth)
|
||||
}
|
||||
|
||||
func (m printConfigModel) contentWithLineNumbers() string {
|
||||
@@ -897,7 +904,7 @@ func (m printConfigModel) contentWithLineNumbers() string {
|
||||
}
|
||||
|
||||
func (m printConfigModel) scrollbar() string {
|
||||
height := m.viewport.Height
|
||||
height := m.viewport.Height()
|
||||
trackHeight := height - scrollbarBottomSpace
|
||||
totalLines := m.viewport.TotalLineCount()
|
||||
|
||||
|
||||
+85
-33
@@ -56,9 +56,9 @@ func NewRequestGenerator(
|
||||
|
||||
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot)
|
||||
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot)
|
||||
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
|
||||
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
|
||||
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
|
||||
paramsGenerator, isParamsGeneratorDynamic, paramKeysAreStatic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
|
||||
headersGenerator, isHeadersGeneratorDynamic, headerKeysAreStatic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
|
||||
cookiesGenerator, isCookiesGeneratorDynamic, cookieKeysAreStatic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
|
||||
|
||||
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
||||
var bodyTemplateRoot *template.Template
|
||||
@@ -83,13 +83,26 @@ func NewRequestGenerator(
|
||||
Cookies: make(map[string][]string),
|
||||
}
|
||||
|
||||
// When a map's key set is fixed, every request refills exactly the same keys, so
|
||||
// the value slices can be truncated and reused instead of being reallocated after
|
||||
// a clear(). Scripts are excluded: they may swap the maps out or add keys of their
|
||||
// own, and they would observe a leftover empty slice where a key used to be absent.
|
||||
reuseParamSlices := paramKeysAreStatic && !hasScripts
|
||||
reuseHeaderSlices := headerKeysAreStatic && !hasScripts
|
||||
reuseCookieSlices := cookieKeysAreStatic && !hasScripts
|
||||
|
||||
var (
|
||||
data valuesData
|
||||
path string
|
||||
err error
|
||||
)
|
||||
return func(req *fasthttp.Request) error {
|
||||
resetRequestData(reqData)
|
||||
resetStringSliceMap(reqData.Headers, reuseHeaderSlices)
|
||||
resetStringSliceMap(reqData.Params, reuseParamSlices)
|
||||
resetStringSliceMap(reqData.Cookies, reuseCookieSlices)
|
||||
reqData.Method = ""
|
||||
reqData.Path = ""
|
||||
reqData.Body = ""
|
||||
|
||||
data, err = valuesGenerator()
|
||||
if err != nil {
|
||||
@@ -143,20 +156,24 @@ func NewRequestGenerator(
|
||||
hasScripts
|
||||
}
|
||||
|
||||
func resetRequestData(reqData *script.RequestData) {
|
||||
reqData.Method = ""
|
||||
reqData.Path = ""
|
||||
reqData.Body = ""
|
||||
clear(reqData.Headers)
|
||||
clear(reqData.Params)
|
||||
clear(reqData.Cookies)
|
||||
// resetStringSliceMap empties m for the next render. With reuse it truncates the
|
||||
// value slices in place so the following appends land in the existing backing
|
||||
// arrays, without it the map is cleared so no stale key can survive.
|
||||
func resetStringSliceMap(m map[string][]string, reuse bool) {
|
||||
if !reuse {
|
||||
clear(m)
|
||||
return
|
||||
}
|
||||
for k, v := range m {
|
||||
m[k] = v[:0]
|
||||
}
|
||||
}
|
||||
|
||||
func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) {
|
||||
req.Header.SetHost(host)
|
||||
req.SetRequestURI(reqData.Path)
|
||||
req.Header.SetMethod(reqData.Method)
|
||||
req.SetBody([]byte(reqData.Body))
|
||||
req.SetBodyString(reqData.Body)
|
||||
|
||||
for k, values := range reqData.Headers {
|
||||
for _, v := range values {
|
||||
@@ -164,20 +181,28 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
|
||||
}
|
||||
}
|
||||
|
||||
for k, values := range reqData.Params {
|
||||
for _, v := range values {
|
||||
req.URI().QueryArgs().Add(k, v)
|
||||
if len(reqData.Params) > 0 {
|
||||
args := req.URI().QueryArgs()
|
||||
for k, values := range reqData.Params {
|
||||
for _, v := range values {
|
||||
args.Add(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(reqData.Cookies) > 0 {
|
||||
cookieStrings := make([]string, 0, len(reqData.Cookies))
|
||||
var sb strings.Builder
|
||||
for k, values := range reqData.Cookies {
|
||||
for _, v := range values {
|
||||
cookieStrings = append(cookieStrings, k+"="+v)
|
||||
if sb.Len() > 0 {
|
||||
sb.WriteString("; ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(v)
|
||||
}
|
||||
}
|
||||
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
|
||||
req.Header.Add("Cookie", sb.String())
|
||||
}
|
||||
|
||||
if scheme == "https" {
|
||||
@@ -221,8 +246,8 @@ func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func()
|
||||
}, isDynamic
|
||||
}
|
||||
|
||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
generators, isDynamic := buildKeyValueGenerators(localRand, params, lazyRoot)
|
||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, params, lazyRoot)
|
||||
|
||||
var (
|
||||
key, value string
|
||||
@@ -243,11 +268,11 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot
|
||||
reqData.Params[key] = append(reqData.Params[key], value)
|
||||
}
|
||||
return nil
|
||||
}, isDynamic
|
||||
}, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
generators, isDynamic := buildKeyValueGenerators(localRand, headers, lazyRoot)
|
||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, headers, lazyRoot)
|
||||
|
||||
var (
|
||||
key, value string
|
||||
@@ -268,11 +293,11 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRo
|
||||
reqData.Headers[key] = append(reqData.Headers[key], value)
|
||||
}
|
||||
return nil
|
||||
}, isDynamic
|
||||
}, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
||||
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
|
||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool, bool) {
|
||||
generators, isDynamic, keysAreStatic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
|
||||
|
||||
var (
|
||||
key, value string
|
||||
@@ -293,14 +318,26 @@ func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRo
|
||||
reqData.Cookies[key] = append(reqData.Cookies[key], value)
|
||||
}
|
||||
return nil
|
||||
}, isDynamic
|
||||
}, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) {
|
||||
// No values configured: hand back one shared empty map instead of allocating a
|
||||
// fresh one for every request. Nothing ever writes to it.
|
||||
if len(values) == 0 {
|
||||
empty := valuesData{Values: map[string]string{}}
|
||||
return func() (valuesData, error) { return empty, nil }
|
||||
}
|
||||
|
||||
generators := make([]func(_ any) (string, error), len(values))
|
||||
|
||||
isDynamic := false
|
||||
for i, v := range values {
|
||||
generators[i], _ = createTemplateFunc(v, lazyRoot)
|
||||
var valueIsDynamic bool
|
||||
generators[i], valueIsDynamic = createTemplateFunc(v, lazyRoot)
|
||||
if valueIsDynamic {
|
||||
isDynamic = true
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -308,7 +345,7 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
|
||||
data map[string]string
|
||||
err error
|
||||
)
|
||||
return func() (valuesData, error) {
|
||||
generate := func() (valuesData, error) {
|
||||
result := make(map[string]string)
|
||||
for _, generator := range generators {
|
||||
rendered, err = generator(nil)
|
||||
@@ -326,6 +363,15 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
|
||||
|
||||
return valuesData{Values: result}, nil
|
||||
}
|
||||
|
||||
// Every value is a literal, so the parsed result is identical on every request:
|
||||
// render and parse once instead of re-running godotenv per request.
|
||||
if !isDynamic {
|
||||
staticResult, staticErr := generate()
|
||||
return func() (valuesData, error) { return staticResult, staticErr }
|
||||
}
|
||||
|
||||
return generate
|
||||
}
|
||||
|
||||
func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) {
|
||||
@@ -335,15 +381,19 @@ func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(
|
||||
|
||||
tmpl, err := lazyRoot().New("").Parse(value)
|
||||
if err == nil && hasTemplateActions(tmpl) {
|
||||
var err error
|
||||
var (
|
||||
buf bytes.Buffer
|
||||
err error
|
||||
)
|
||||
return func(data any) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
buf.Reset()
|
||||
if err = tmpl.Execute(&buf, data); err != nil {
|
||||
return "", types.NewTemplateRenderError(err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}, true
|
||||
}
|
||||
|
||||
return func(_ any) (string, error) { return value, nil }, false
|
||||
}
|
||||
|
||||
@@ -360,8 +410,9 @@ func buildKeyValueGenerators[T keyValueItem](
|
||||
localRand *rand.Rand,
|
||||
items []T,
|
||||
lazyRoot func() *template.Template,
|
||||
) ([]keyValueGenerator, bool) {
|
||||
) ([]keyValueGenerator, bool, bool) {
|
||||
isDynamic := false
|
||||
keysAreStatic := true
|
||||
generators := make([]keyValueGenerator, len(items))
|
||||
|
||||
for generatorIndex, item := range items {
|
||||
@@ -372,6 +423,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
||||
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot)
|
||||
if keyIsDynamic {
|
||||
isDynamic = true
|
||||
keysAreStatic = false
|
||||
}
|
||||
|
||||
// Generate value functions
|
||||
@@ -394,7 +446,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
||||
}
|
||||
}
|
||||
|
||||
return generators, isDynamic
|
||||
return generators, isDynamic, keysAreStatic
|
||||
}
|
||||
|
||||
func buildStringSliceGenerator(
|
||||
|
||||
@@ -2,7 +2,6 @@ package sarin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"slices"
|
||||
@@ -10,8 +9,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/lipgloss/table"
|
||||
"charm.land/lipgloss/v2"
|
||||
"charm.land/lipgloss/v2/table"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
@@ -159,7 +158,7 @@ func (data *SarinResponseData) PrintTable() {
|
||||
return cellStyle
|
||||
})
|
||||
|
||||
fmt.Println(tbl)
|
||||
lipgloss.Println(tbl)
|
||||
}
|
||||
|
||||
func (data *SarinResponseData) PrintJSON() {
|
||||
|
||||
@@ -51,6 +51,18 @@ func SplitLogLevels(levels string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// IsInteractiveTerminal reports whether fd is a terminal that can actually be
|
||||
// drawn on. A pty reports as a terminal even while its size is still zero, and
|
||||
// Bubble Tea paints into a width x height cell buffer, so a zero dimension
|
||||
// renders nothing at all.
|
||||
func IsInteractiveTerminal(fd uintptr) bool {
|
||||
if !term.IsTerminal(fd) {
|
||||
return false
|
||||
}
|
||||
width, height, err := term.GetSize(fd)
|
||||
return err == nil && width > 0 && height > 0
|
||||
}
|
||||
|
||||
// respLogger logs a single completed response.
|
||||
type respLogger func(duration time.Duration, resp *fasthttp.Response)
|
||||
|
||||
@@ -302,7 +314,7 @@ func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
|
||||
totalRequests = *s.totalRequests
|
||||
}
|
||||
|
||||
onTerminal := term.IsTerminal(os.Stdout.Fd())
|
||||
onTerminal := IsInteractiveTerminal(os.Stdout.Fd())
|
||||
// The progress bar needs an interactive terminal to render.
|
||||
showProgressBar := s.showProgress && onTerminal
|
||||
// The bubbletea TUI hosts the bar and/or the live log box, so it runs whenever
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
const forceExitCode = 130
|
||||
|
||||
+67
-23
@@ -2,19 +2,30 @@ package sarin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"charm.land/bubbles/v2/progress"
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
// logBatch carries the log lines accumulated since the last repaint.
|
||||
type logBatch []runtimeLog
|
||||
|
||||
const (
|
||||
// logBoxLines is how many lines the log box shows at once.
|
||||
logBoxLines = 8
|
||||
// logBatchInterval is how often accumulated logs are handed to the program.
|
||||
logBatchInterval = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
var (
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
|
||||
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
|
||||
@@ -82,22 +93,24 @@ func (m progressModel) Init() tea.Cmd {
|
||||
|
||||
func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
m.cancelling = true
|
||||
m.stop()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.progress.Width = max(10, msg.Width-1)
|
||||
m.progress.SetWidth(max(10, msg.Width-1))
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case runtimeLog:
|
||||
m.logs = append(m.logs[1:], renderRuntimeLog(msg))
|
||||
case logBatch:
|
||||
for _, entry := range msg {
|
||||
m.logs = append(m.logs[1:], renderRuntimeLog(entry))
|
||||
}
|
||||
if m.ctx.Err() != nil {
|
||||
return m, tea.Quit
|
||||
}
|
||||
@@ -117,7 +130,7 @@ func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m progressModel) View() string {
|
||||
func (m progressModel) View() tea.View {
|
||||
var b strings.Builder
|
||||
if box := renderLogBox(m.logs); box != "" {
|
||||
b.WriteString(box)
|
||||
@@ -138,7 +151,7 @@ func (m progressModel) View() string {
|
||||
|
||||
b.WriteString("\n\n ")
|
||||
b.WriteString(helpLine(m.cancelling))
|
||||
return b.String()
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
|
||||
func progressTickCmd() tea.Cmd {
|
||||
@@ -167,15 +180,17 @@ func (m infiniteProgressModel) Init() tea.Cmd {
|
||||
|
||||
func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
case tea.KeyPressMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
m.cancelling = true
|
||||
m.stop()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case runtimeLog:
|
||||
m.logs = append(m.logs[1:], renderRuntimeLog(msg))
|
||||
case logBatch:
|
||||
for _, entry := range msg {
|
||||
m.logs = append(m.logs[1:], renderRuntimeLog(entry))
|
||||
}
|
||||
if m.ctx.Err() != nil {
|
||||
m.quit = true
|
||||
return m, tea.Quit
|
||||
@@ -193,7 +208,7 @@ func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m infiniteProgressModel) View() string {
|
||||
func (m infiniteProgressModel) View() tea.View {
|
||||
var b strings.Builder
|
||||
if box := renderLogBox(m.logs); box != "" {
|
||||
b.WriteString(box)
|
||||
@@ -206,7 +221,7 @@ func (m infiniteProgressModel) View() string {
|
||||
b.WriteString("\n\n ")
|
||||
b.WriteString(helpLine(m.cancelling))
|
||||
}
|
||||
return b.String()
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
|
||||
if m.quit {
|
||||
@@ -227,7 +242,7 @@ func (m infiniteProgressModel) View() string {
|
||||
b.WriteString("\n\n ")
|
||||
b.WriteString(helpLine(m.cancelling))
|
||||
}
|
||||
return b.String()
|
||||
return tea.NewView(b.String())
|
||||
}
|
||||
|
||||
func (s sarin) streamProgress(
|
||||
@@ -242,9 +257,12 @@ func (s sarin) streamProgress(
|
||||
var program *tea.Program
|
||||
if total > 0 {
|
||||
model := progressModel{
|
||||
progress: progress.New(progress.WithGradient("#151594", "#00D4FF")),
|
||||
progress: progress.New(
|
||||
progress.WithColors(lipgloss.Color("#151594"), lipgloss.Color("#00D4FF")),
|
||||
progress.WithFillCharacters(progress.DefaultFullCharFullBlock, progress.DefaultEmptyCharBlock),
|
||||
),
|
||||
startTime: time.Now(),
|
||||
logs: make([]string, 8),
|
||||
logs: make([]string, logBoxLines),
|
||||
counter: counter,
|
||||
maxValue: total,
|
||||
showBar: showBar,
|
||||
@@ -275,7 +293,7 @@ func (s sarin) streamProgress(
|
||||
),
|
||||
startTime: time.Now(),
|
||||
counter: counter,
|
||||
logs: make([]string, 8),
|
||||
logs: make([]string, logBoxLines),
|
||||
showBar: showBar,
|
||||
ctx: ctx,
|
||||
stop: stopCtrl.Stop,
|
||||
@@ -288,9 +306,35 @@ func (s sarin) streamProgress(
|
||||
stopCtrl.AttachProgram(program)
|
||||
defer stopCtrl.AttachProgram(nil)
|
||||
|
||||
// Bubble Tea repaints once per message, so forwarding every log made the run
|
||||
// wait on the terminal. Send only what the box shows, on a fixed cadence.
|
||||
go func() {
|
||||
for msg := range logChannel {
|
||||
program.Send(msg)
|
||||
ticker := time.NewTicker(logBatchInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
pending := make(logBatch, 0, logBoxLines)
|
||||
flush := func() {
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
program.Send(slices.Clone(pending))
|
||||
pending = pending[:0]
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-logChannel:
|
||||
if !ok {
|
||||
flush()
|
||||
return
|
||||
}
|
||||
if len(pending) == logBoxLines {
|
||||
pending = append(pending[:0], pending[1:]...)
|
||||
}
|
||||
pending = append(pending, entry)
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -12,19 +12,19 @@ import (
|
||||
const dryRunResponseKey = "dry-run"
|
||||
|
||||
// statusCodeStrings contains pre-computed string representations for HTTP status codes 100-599.
|
||||
var statusCodeStrings = func() map[int]string {
|
||||
m := make(map[int]string, 500)
|
||||
for i := 100; i < 600; i++ {
|
||||
m[i] = strconv.Itoa(i)
|
||||
var statusCodeStrings = func() [500]string {
|
||||
var codes [500]string
|
||||
for i := range codes {
|
||||
codes[i] = strconv.Itoa(i + 100)
|
||||
}
|
||||
return m
|
||||
return codes
|
||||
}()
|
||||
|
||||
// statusCodeToString returns a string representation of the HTTP status code.
|
||||
// Uses a pre-computed map for codes 100-599, falls back to strconv.Itoa for others.
|
||||
// Uses a pre-computed table for codes 100-599, falls back to strconv.Itoa for others.
|
||||
func statusCodeToString(code int) string {
|
||||
if s, ok := statusCodeStrings[code]; ok {
|
||||
return s
|
||||
if i := code - 100; i >= 0 && i < len(statusCodeStrings) {
|
||||
return statusCodeStrings[i]
|
||||
}
|
||||
return strconv.Itoa(code)
|
||||
}
|
||||
@@ -94,7 +94,6 @@ func (s sarin) workerStatsWithDynamic(
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
resp.Reset()
|
||||
|
||||
if err := requestGenerator(req); err != nil {
|
||||
s.responses.Add(err.Error(), 0)
|
||||
@@ -138,8 +137,6 @@ func (s sarin) workerStatsWithStatic(
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
resp.Reset()
|
||||
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||
respDuration := time.Since(startTime)
|
||||
@@ -165,7 +162,6 @@ func (s sarin) workerNoStatsWithDynamic(
|
||||
) {
|
||||
for range jobs {
|
||||
req.Reset()
|
||||
resp.Reset()
|
||||
if err := requestGenerator(req); err != nil {
|
||||
sendLog(runtimeLogLevelError, err.Error())
|
||||
counter.Add(1)
|
||||
@@ -201,7 +197,6 @@ func (s sarin) workerNoStatsWithStatic(
|
||||
}
|
||||
|
||||
for range jobs {
|
||||
resp.Reset()
|
||||
startTime := time.Now()
|
||||
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
|
||||
if err == nil {
|
||||
|
||||
+2
-3
@@ -7,11 +7,11 @@
|
||||
|
||||
(buildGoModule.override { go = go_1_26; }) (finalAttrs: {
|
||||
pname = "sarin";
|
||||
version = "1.4.0"; # bump per release
|
||||
version = "1.4.2"; # bump per release
|
||||
|
||||
src = lib.cleanSource ../.;
|
||||
|
||||
vendorHash = "sha256-/r2mioVoMbrboumF0sjHhharkGImQAShmiOQtdS5DaE=";
|
||||
vendorHash = "sha256-Yn1d2NbPYhlXzoJl4QbmZ/K7/UdwheWLj2mLyegRPvM=";
|
||||
|
||||
subPackages = [ "cmd/cli" ];
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
ldflags+=("-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)'")
|
||||
'';
|
||||
|
||||
# cmd/cli produces a binary named "cli"; rename it to "sarin".
|
||||
postInstall = ''
|
||||
mv $out/bin/cli $out/bin/sarin
|
||||
'';
|
||||
|
||||
Reference in New Issue
Block a user