Compare commits

...
57 Commits
Author SHA1 Message Date
aykhans 8beff615bb Merge pull request #221 from aykhans/dependabot/go_modules/charm.land/bubbles/v2-2.2.0
build(deps): bump charm.land/bubbles/v2 from 2.1.1 to 2.2.0
2026-08-25 10:43:40 +04:00
aykhans a85c82e751 fix(nix): update vendorHash for bubbles 2.2.0 2026-08-25 10:28:29 +04:00
dependabot[bot] ad5cfadaf2 build(deps): bump charm.land/bubbles/v2 from 2.1.1 to 2.2.0
Bumps [charm.land/bubbles/v2](https://github.com/charmbracelet/bubbles) from 2.1.1 to 2.2.0.
- [Release notes](https://github.com/charmbracelet/bubbles/releases)
- [Commits](https://github.com/charmbracelet/bubbles/compare/v2.1.1...v2.2.0)

---
updated-dependencies:
- dependency-name: charm.land/bubbles/v2
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 00:12:58 +00:00
aykhans 8078b2c1fe Merge pull request #220 from aykhans/dependabot/go_modules/charm.land/bubbletea/v2-2.0.9
build(deps): bump charm.land/bubbletea/v2 from 2.0.8 to 2.0.9
2026-08-24 16:07:49 +04:00
aykhans 3c4f449c97 fix(nix): update vendorHash for bubbletea 2.0.9 2026-08-24 15:46:34 +04:00
dependabot[bot] deba048e3c build(deps): bump charm.land/bubbletea/v2 from 2.0.8 to 2.0.9
Bumps [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) from 2.0.8 to 2.0.9.
- [Release notes](https://github.com/charmbracelet/bubbletea/releases)
- [Commits](https://github.com/charmbracelet/bubbletea/compare/v2.0.8...v2.0.9)

---
updated-dependencies:
- dependency-name: charm.land/bubbletea/v2
  dependency-version: 2.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 11:23:33 +00:00
aykhans 77e8bc1eb8 Merge pull request #219 from aykhans/dependabot/go_modules/github.com/brianvoe/gofakeit/v7-7.16.0
build(deps): bump github.com/brianvoe/gofakeit/v7 from 7.15.0 to 7.16.0
2026-08-24 15:21:40 +04:00
aykhans a81d95ef6d fix(nix): update vendorHash for gofakeit 7.16.0 2026-08-24 15:20:10 +04:00
dependabot[bot] 8195b92547 build(deps): bump github.com/brianvoe/gofakeit/v7 from 7.15.0 to 7.16.0
Bumps [github.com/brianvoe/gofakeit/v7](https://github.com/brianvoe/gofakeit) from 7.15.0 to 7.16.0.
- [Release notes](https://github.com/brianvoe/gofakeit/releases)
- [Commits](https://github.com/brianvoe/gofakeit/compare/v7.15.0...v7.16.0)

---
updated-dependencies:
- dependency-name: github.com/brianvoe/gofakeit/v7
  dependency-version: 7.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-24 00:15:38 +00:00
aykhans f42c4c70bf Merge pull request #218 from aykhans/ci/verify-package-version
ci: gate releases on the nix/package.nix version, bump to 1.4.2
2026-08-18 13:06:58 +04:00
aykhans f3f25e3ba1 chore: bump package version to 1.4.2 2026-08-18 13:03:22 +04:00
aykhans 8121d75a2c ci: verify nix/package.nix version matches the release tag 2026-08-18 13:03:07 +04:00
aykhans 3867140704 Merge pull request #217 from aykhans/chore/update-deps
chore: migrate to Charm v2 and update dependencies
2026-08-18 12:42:46 +04:00
aykhans 740b6f6bbb docs: update README 2026-08-17 21:16:40 +04:00
aykhans 88180ae288 ci: add nix build workflow and gate releases on it 2026-08-17 21:13:00 +04:00
aykhans 0314ea4f3e docs: update README 2026-08-17 20:56:46 +04:00
aykhans 9602595216 perf: batch TUI log forwarding on a 250ms tick instead of one repaint per line 2026-08-17 20:46:33 +04:00
aykhans c195ca2ab2 refactor: print stderr via fmt.Fprint with lipgloss.Sprintln instead of lipgloss.Fprintln 2026-08-17 20:11:47 +04:00
aykhans e13eb9b8a8 fix: treat zero-sized ptys as non-interactive 2026-08-17 19:11:55 +04:00
aykhans d68c557dd3 fix: restore pre-v2 progress bar fill and viewport scroll behavior 2026-08-17 18:57:33 +04:00
aykhans d08b6dc222 refactor: migrate Charm libraries to v2
Import paths moved to charm.land/*/v2 and the v1 paths no longer resolve.
Ports View() -> tea.View, AltScreen/MouseMode to View fields, KeyPressMsg,
and the new progress/viewport constructors. Styled output outside Bubble
Tea switched to lipgloss.Print* because v2 downsamples colour at the output
layer. vendorHash refreshed.
2026-08-17 17:21:13 +04:00
aykhans 8552aab3b1 chore(deps): update goja and indirect deps, refresh nix vendorHash 2026-08-17 13:12:39 +04:00
aykhans 79143beec9 Merge pull request #216 from aykhans/dependabot/go_modules/golang.org/x/net-0.58.0
build(deps): bump golang.org/x/net from 0.57.0 to 0.58.0
2026-08-17 12:02:46 +04:00
dependabot[bot] bb1ed1c2ce build(deps): bump golang.org/x/net from 0.57.0 to 0.58.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.57.0 to 0.58.0.
- [Commits](https://github.com/golang/net/compare/v0.57.0...v0.58.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.58.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 00:17:00 +00:00
aykhans a3434b096a Merge pull request #215 from aykhans/perf/remove-hot-path-contention
perf
2026-08-13 12:29:54 +04:00
aykhans e83e9346a3 perf: drop redundant resp.Reset in worker loops
fasthttp resets the response at the top of every HostClient.Do, so
resetting it in the worker loop only repeats that work once per request.
2026-08-10 00:10:27 +04:00
aykhans 01b5233167 perf: replace status code string map with an indexed table 2026-08-10 00:01:16 +04:00
aykhans e93b729145 Merge pull request #214 from aykhans/fix/nix-vendor-hash
nix: update vendorHash
2026-08-02 23:35:34 +04:00
aykhans 372d228bb1 nix: update vendorHash 2026-08-02 23:35:12 +04:00
aykhans 52a6e78e0c Merge pull request #213 from aykhans/perf/reduce-per-request-overhead
perf: reduce per-request overhead in the load generator
2026-08-02 22:39:48 +04:00
aykhans b22e3a0d25 nix: bump package version to 1.4.1 2026-08-02 22:37:56 +04:00
aykhans 4b52556b30 update README.md 2026-08-02 22:29:59 +04:00
aykhans 755545d838 perf: cut per-request allocations in the request generator
- Reuse value slices in place instead of clear()+realloc when a map's
  key set is static (skipped when scripts may mutate the maps).
- Use SetBodyString to drop the []byte(Body) copy, build the Cookie
  header with a strings.Builder, and skip QueryArgs when no params.
- Cache the values map when all values are literal, and share one empty
  map when none are configured.
- Reuse a single bytes.Buffer across template renders per worker.
2026-08-02 20:11:56 +04:00
aykhans dc2cd6cf63 Merge pull request #211 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.73.0
build(deps): bump github.com/valyala/fasthttp from 1.72.0 to 1.73.0
2026-07-28 13:34:06 +04:00
dependabot[bot] 838d65d83f build(deps): bump github.com/valyala/fasthttp from 1.72.0 to 1.73.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.72.0 to 1.73.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.72.0...v1.73.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.73.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 00:12:42 +00:00
aykhans 0fe0587823 Merge pull request #210 from aykhans/feat/nix-flake-packaging
Add nix flake packaging
2026-07-26 23:29:39 +04:00
aykhans 5b301c42a9 docs: unpin nix install commands in README 2026-07-26 23:26:19 +04:00
aykhans d35295c1d9 chore: bump go to 1.26.5 and update dependencies 2026-07-26 23:21:33 +04:00
aykhans a13d9d11f7 feat: add nix flake packaging 2026-07-26 22:55:24 +04:00
aykhans 3393f9e767 Merge pull request #209 from aykhans/docs/update-readme-badges
docs: update README badges
2026-07-24 00:22:47 +04:00
aykhans 2144f8e67d docs: update README badges 2026-07-24 00:21:48 +04:00
aykhans 9d1a73dd43 Merge pull request #207 from aykhans/feat/runtime-logging
feat(logging): add runtime log levels, response logging, and progress/log-file controls
2026-07-23 15:46:02 +04:00
aykhans f4d13d83aa fix(logging): match log levels case-sensitively 2026-07-23 00:15:21 +04:00
aykhans 07e9287c39 docs: document logging and progress config, drop quiet 2026-07-22 23:55:50 +04:00
aykhans 80a8ba1d59 feat(logging): add runtime log levels, response logging, and progress/log-file controls
- --log-level (comma-separated string: info, error): pick which logs are emitted;
  the level set is resolved once, up front
- Log responses at the info level: full self-contained JSON to a file or stderr,
  a compact "status duration | body" summary in the TUI log box
- Route logs independently of the progress display: the TUI log box when it runs,
  stderr when piped, or a file via --log-file/-w (parent dir validated up front)
- Replace --quiet with --progress (bar | none, default bar): progress and logs are
  now independent — progress=none still shows the log box; quiet == progress=none
  with an empty log level
- Rename the internal "message" concept to "log" (runtimeLog, runtimeLogger, ...)
2026-07-22 23:20:14 +04:00
aykhans 31af2d0d3c Merge pull request #206 from aykhans/dependabot/go_modules/golang.org/x/net-0.57.0
build(deps): bump golang.org/x/net from 0.56.0 to 0.57.0
2026-07-09 10:39:21 +04:00
dependabot[bot] 02b28dba2f build(deps): bump golang.org/x/net from 0.56.0 to 0.57.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.56.0 to 0.57.0.
- [Commits](https://github.com/golang/net/compare/v0.56.0...v0.57.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 00:12:53 +00:00
aykhans d7bc0706ca Merge pull request #205 from aykhans/dependabot/go_modules/github.com/valyala/fasthttp-1.72.0
build(deps): bump github.com/valyala/fasthttp from 1.71.0 to 1.72.0
2026-07-01 15:32:01 +04:00
dependabot[bot] 6e5c6eb0fe build(deps): bump github.com/valyala/fasthttp from 1.71.0 to 1.72.0
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.71.0 to 1.72.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.71.0...v1.72.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.72.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-30 00:12:44 +00:00
aykhans a349ffd6dd Merge pull request #204 from aykhans/dependabot/go_modules/go.yaml.in/yaml/v4-4.0.0-rc.6
build(deps): bump go.yaml.in/yaml/v4 from 4.0.0-rc.5 to 4.0.0-rc.6
2026-06-22 12:08:01 +04:00
dependabot[bot] 606aec693c build(deps): bump go.yaml.in/yaml/v4 from 4.0.0-rc.5 to 4.0.0-rc.6
Bumps [go.yaml.in/yaml/v4](https://github.com/yaml/go-yaml) from 4.0.0-rc.5 to 4.0.0-rc.6.
- [Commits](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.5...v4.0.0-rc.6)

---
updated-dependencies:
- dependency-name: go.yaml.in/yaml/v4
  dependency-version: 4.0.0-rc.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 00:15:53 +00:00
aykhans 99e442691d Merge pull request #203 from aykhans/dependabot/go_modules/golang.org/x/net-0.56.0
build(deps): bump golang.org/x/net from 0.55.0 to 0.56.0
2026-06-11 12:16:40 +04:00
dependabot[bot] 11ddabc7d4 build(deps): bump golang.org/x/net from 0.55.0 to 0.56.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.55.0 to 0.56.0.
- [Commits](https://github.com/golang/net/compare/v0.55.0...v0.56.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 00:12:53 +00:00
aykhans dd23ea73fd Merge pull request #202 from aykhans/dependabot/go_modules/go.yaml.in/yaml/v4-4.0.0-rc.5
build(deps): bump go.yaml.in/yaml/v4 from 4.0.0-rc.4 to 4.0.0-rc.5
2026-06-10 11:38:33 +04:00
dependabot[bot] c4faac9127 build(deps): bump go.yaml.in/yaml/v4 from 4.0.0-rc.4 to 4.0.0-rc.5
Bumps [go.yaml.in/yaml/v4](https://github.com/yaml/go-yaml) from 4.0.0-rc.4 to 4.0.0-rc.5.
- [Commits](https://github.com/yaml/go-yaml/compare/v4.0.0-rc.4...v4.0.0-rc.5)

---
updated-dependencies:
- dependency-name: go.yaml.in/yaml/v4
  dependency-version: 4.0.0-rc.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-10 00:12:54 +00:00
aykhans 7a75a4e096 Merge pull request #201 from aykhans/docs/fix-cycling-description
docs: fix multi-value cycling description for method, body, and proxy
2026-06-07 01:18:47 +04:00
aykhans ac6f1a1a65 docs: fix multi-value cycling description for method, body, and proxy 2026-06-07 01:18:24 +04:00
26 changed files with 1371 additions and 641 deletions
+3 -3
View File
@@ -13,10 +13,10 @@ jobs:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: 1.26.4
go-version: 1.26.5
- name: go fix
run: |
go fix ./...
+36
View File
@@ -0,0 +1,36 @@
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
jobs:
build:
name: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.ref }}
- uses: cachix/install-nix-action@v31
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- name: nix build
run: nix build .#default --no-link --print-build-logs
+39 -3
View File
@@ -21,13 +21,49 @@ permissions:
contents: write
jobs:
nix:
name: Verify Nix package
uses: ./.github/workflows/nix.yaml
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:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ inputs.tag || github.ref }}
@@ -35,11 +71,11 @@ jobs:
run: |
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
echo "GO_VERSION=1.26.4" >> $GITHUB_ENV
echo "GO_VERSION=1.26.5" >> $GITHUB_ENV
- name: Set up Go
if: github.event_name == 'release' || inputs.build_binaries
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: ${{ env.GO_VERSION }}
cache: true
+4
View File
@@ -1 +1,5 @@
bin/*
# Nix build artifacts
result
result-*
+5
View File
@@ -81,6 +81,11 @@ linters:
- staticcheck
text: "SA5011"
- linters:
- errcheck
- gosec
source: "lipgloss\\.Println\\("
formatters:
enable:
- gofmt
+1 -1
View File
@@ -1,4 +1,4 @@
ARG GO_VERSION=1.26.4
ARG GO_VERSION=1.26.5
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
+47 -9
View File
@@ -3,7 +3,9 @@
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
[![Go Reference](https://pkg.go.dev/badge/go.aykhans.me/sarin.svg)](https://pkg.go.dev/go.aykhans.me/sarin)
[![Go Report Card](https://goreportcard.com/badge/go.aykhans.me/sarin)](https://goreportcard.com/report/go.aykhans.me/sarin)
[![Docker Pulls](https://img.shields.io/docker/pulls/aykhans/sarin)](https://hub.docker.com/r/aykhans/sarin)
[![Downloads](https://img.shields.io/github/downloads/aykhans/sarin/total?cacheSeconds=3600)](https://github.com/aykhans/sarin/releases)
[![Lint](https://img.shields.io/github/actions/workflow/status/aykhans/sarin/lint.yaml?branch=main&label=lint)](https://github.com/aykhans/sarin/actions/workflows/lint.yaml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
</div>
@@ -24,20 +26,19 @@ 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
### Docker (Recommended)
<details open>
<summary><b>Docker</b></summary>
```sh
docker pull aykhans/sarin:latest
docker run --rm -it aykhans/sarin:latest --version
```
With a local config file:
@@ -52,11 +53,46 @@ With a remote config file:
docker run --rm -it aykhans/sarin -f https://example.com/config.yaml
```
### Pre-built Binaries
</details>
<details>
<summary><b>Nix</b></summary>
Run directly without installing (requires flakes enabled):
```sh
nix run github:aykhans/sarin -- -U http://example.com -r 100 -c 10
```
Install into your profile:
```sh
nix profile install github:aykhans/sarin
```
Or add it to your own flake via the overlay:
```nix
{
inputs.sarin.url = "github:aykhans/sarin";
# In your outputs, apply the overlay to nixpkgs:
# nixpkgs.overlays = [ inputs.sarin.overlays.default ];
# then reference pkgs.sarin
}
```
</details>
<details>
<summary><b>Pre-built Binaries</b></summary>
Download the latest binaries from the [releases](https://github.com/aykhans/sarin/releases) page.
### Building from Source
</details>
<details>
<summary><b>Building from Source</b></summary>
Requires [Go 1.26+](https://golang.org/dl/).
@@ -72,6 +108,8 @@ CGO_ENABLED=0 go build \
-o sarin ./cmd/cli/main.go
```
</details>
## Quick Start
Send 10,000 GET requests with 50 concurrent connections and a random User-Agent for each request:
+10 -9
View File
@@ -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)
@@ -50,25 +51,25 @@ func main() {
ctx,
combinedConfig.Methods, combinedConfig.URL, *combinedConfig.Timeout,
*combinedConfig.Concurrency, combinedConfig.Requests, combinedConfig.Duration,
*combinedConfig.Quiet, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
*combinedConfig.Progress == config.ConfigProgressTypeBar, *combinedConfig.Insecure, combinedConfig.Params, combinedConfig.Headers,
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
*combinedConfig.Output != config.ConfigOutputTypeNone,
*combinedConfig.DryRun,
*combinedConfig.DryRun, *combinedConfig.LogLevel, *combinedConfig.LogFile,
combinedConfig.Lua, combinedConfig.Js,
)
_ = 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
}),
+60 -39
View File
@@ -26,7 +26,9 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
| [Concurrency](#concurrency) | `concurrency`<br>(number) | `-concurrency` / `-c`<br>(number) | `SARIN_CONCURRENCY`<br>(number) | `1` | Number of concurrent workers |
| [Requests](#requests) | `requests`<br>(number) | `-requests` / `-r`<br>(number) | `SARIN_REQUESTS`<br>(number) | - | Total requests to send |
| [Duration](#duration) | `duration`<br>(duration) | `-duration` / `-d`<br>(duration) | `SARIN_DURATION`<br>(duration) | - | Test duration |
| [Quiet](#quiet) | `quiet`<br>(boolean) | `-quiet` / `-q`<br>(boolean) | `SARIN_QUIET`<br>(boolean) | `false` | Hide progress bar and logs |
| [Log Level](#log-level) | `logLevel`<br>(string) | `-log-level` / `-l`<br>(string) | `SARIN_LOG_LEVEL`<br>(string) | `error` | Runtime log levels to emit |
| [Log File](#log-file) | `logFile`<br>(string) | `-log-file` / `-w`<br>(string) | `SARIN_LOG_FILE`<br>(string) | - | Write runtime logs to a file |
| [Progress](#progress) | `progress`<br>(string) | `-progress` / `-p`<br>(string) | `SARIN_PROGRESS`<br>(string) | `bar` | Progress display (bar/none) |
| [Output](#output) | `output`<br>(string) | `-output` / `-o`<br>(string) | `SARIN_OUTPUT`<br>(string) | `table` | Output format for stats |
| [Dry Run](#dry-run) | `dryRun`<br>(boolean) | `-dry-run` / `-z`<br>(boolean) | `SARIN_DRY_RUN`<br>(boolean) | `false` | Generate without sending |
| [Insecure](#insecure) | `insecure`<br>(boolean) | `-insecure` / `-I`<br>(boolean) | `SARIN_INSECURE`<br>(boolean) | `false` | Skip TLS verification |
@@ -131,7 +133,7 @@ sarin -U "http://example.com/users/{{ fakeit_UUID }}" -r 1000 -c 10
## Method
HTTP method(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
HTTP method(s). Defaults to `GET`. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md).
**YAML example:**
@@ -141,9 +143,9 @@ method: GET
# OR
method:
- GET
- POST
- PUT
- GET
- POST
- PUT
```
**CLI example:**
@@ -160,7 +162,7 @@ SARIN_METHOD=GET
## Timeout
Request timeout. Must be greater than 0.
Request timeout. Must be greater than 0. Defaults to `10s`.
Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
@@ -168,7 +170,7 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
## Concurrency
Number of concurrent workers. Must be between 1 and 100,000,000.
Number of concurrent workers. Must be between 1 and 100,000,000. Defaults to `1`.
## Requests
@@ -182,15 +184,34 @@ Valid time units: `ns`, `us` (or `µs`), `ms`, `s`, `m`, `h`
**Examples:** `1m30s`, `25s`, `1h`
## Quiet
## Log Level
Hide the progress bar and runtime logs.
Runtime log levels to emit, comma-separated. Valid levels: `info`, `error`. Defaults to `error`.
- `error`: errors that occur while generating or sending a request
- `info`: every completed response
Leave empty to disable logging entirely.
**Examples:** `error` (only errors), `info` (only responses), `info,error` (both)
## Log File
Write runtime logs to this file instead of the terminal or stderr. The parent directory must exist.
```sh
sarin -U http://example.com -r 1000 --log-file ./run.log
```
## Progress
Progress display. Valid values: `bar` (default), `none`. Use `none` to hide the progress bar.
## Output
Output format for response statistics.
Valid formats: `table`, `json`, `yaml`, `none`
Valid formats: `table` (default), `json`, `yaml`, `none`
Using `none` disables output and reduces memory usage since response statistics are not stored.
@@ -204,7 +225,7 @@ Skip TLS certificate verification.
## Body
Request body. If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
Request body. If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point. Supports [templating](templating.md).
**YAML example:**
@@ -214,9 +235,9 @@ body: '{"product": "car"}'
# OR
body:
- '{"product": "car"}'
- '{"product": "phone"}'
- '{"product": "watch"}'
- '{"product": "car"}'
- '{"product": "phone"}'
- '{"product": "watch"}'
```
**CLI example:**
@@ -241,19 +262,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
```yaml
params:
key1: value1
key2: [value2, value3] # cycles between value2 and value3
key1: value1
key2: [value2, value3] # cycles between value2 and value3
# OR
params:
- key1: value1
- key2: [value2, value3] # cycles between value2 and value3
- key1: value1
- key2: [value2, value3] # cycles between value2 and value3
# To send both values in every request, use separate entries:
params:
- key2: value2
- key2: value3 # both sent in every request
- key2: value2
- key2: value3 # both sent in every request
```
**CLI example:**
@@ -278,19 +299,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
```yaml
headers:
key1: value1
key2: [value2, value3] # cycles between value2 and value3
key1: value1
key2: [value2, value3] # cycles between value2 and value3
# OR
headers:
- key1: value1
- key2: [value2, value3] # cycles between value2 and value3
- key1: value1
- key2: [value2, value3] # cycles between value2 and value3
# To send both values in every request, use separate entries:
headers:
- key2: value2
- key2: value3 # both sent in every request
- key2: value2
- key2: value3 # both sent in every request
```
**CLI example:**
@@ -315,19 +336,19 @@ When the same key appears as **separate entries** (in CLI or config file), all v
```yaml
cookies:
key1: value1
key2: [value2, value3] # cycles between value2 and value3
key1: value1
key2: [value2, value3] # cycles between value2 and value3
# OR
cookies:
- key1: value1
- key2: [value2, value3] # cycles between value2 and value3
- key1: value1
- key2: [value2, value3] # cycles between value2 and value3
# To send both values in every request, use separate entries:
cookies:
- key2: value2
- key2: value3 # both sent in every request
- key2: value2
- key2: value3 # both sent in every request
```
**CLI example:**
@@ -344,7 +365,7 @@ SARIN_COOKIE="key1=value1"
## Proxy
Proxy URL(s). If multiple values are provided, Sarin cycles through them in order, starting from a random index for each request.
Proxy URL(s). If multiple values are provided, Sarin starts at a random index and cycles through them in order. Once the cycle completes, it picks a new random starting point.
Supported protocols: `http`, `https`, `socks5`, `socks5h`
@@ -356,9 +377,9 @@ proxy: http://proxy1.com
# OR
proxy:
- http://proxy1.com
- socks5://proxy2.com
- socks5h://proxy3.com
- http://proxy1.com
- socks5://proxy2.com
- socks5h://proxy3.com
```
**CLI example:**
@@ -387,9 +408,9 @@ values: "key=value"
# OR
values: |
key1=value1
key2=value2
key3=value3
key1=value1
key2=value2
key3=value3
```
**CLI example:**
+40 -4
View File
@@ -13,6 +13,7 @@ This guide provides practical examples for common Sarin use cases.
- [File Uploads](#file-uploads)
- [Using Proxies](#using-proxies)
- [Output Formats](#output-formats)
- [Runtime Logging](#runtime-logging)
- [Docker Usage](#docker-usage)
- [Dry Run Mode](#dry-run-mode)
- [Show Configuration](#show-configuration)
@@ -719,7 +720,7 @@ proxy: socks5://proxy.example.com:1080
</details>
**Multiple proxies (load balanced):**
**Multiple proxies (randomly cycled):**
```sh
sarin -U http://example.com -r 1000 -c 10 \
@@ -836,10 +837,10 @@ output: none
</details>
**Quiet mode (hide progress bar):**
**Hide the progress bar:**
```sh
sarin -U http://example.com -r 1000 -c 10 -q
sarin -U http://example.com -r 1000 -c 10 -p none
```
<details>
@@ -849,7 +850,42 @@ sarin -U http://example.com -r 1000 -c 10 -q
url: http://example.com
requests: 1000
concurrency: 10
quiet: true
progress: none
```
</details>
## Runtime Logging
`--log-level` selects which runtime logs Sarin emits (comma-separated `info` and `error`, default `error`). `error` covers request and generation errors, `info` covers every completed response (status, duration, headers, body). Logs appear in the progress log box on an interactive terminal, go to stderr when piped, or go to a file with `--log-file`.
**Log responses and errors:**
```sh
sarin -U http://example.com -r 1000 -c 10 -l info,error
```
**Write logs to a file (the progress bar stays on screen):**
```sh
sarin -U http://example.com -r 1000 -c 10 -l info --log-file ./run.log
```
**Capture logs while keeping results on stdout:**
```sh
sarin -U http://example.com -r 1000 -l info -o json > stats.json 2> run.log
```
<details>
<summary>YAML equivalent</summary>
```yaml
url: http://example.com
requests: 1000
concurrency: 10
logLevel: info,error
logFile: ./run.log
```
</details>
+12 -12
View File
@@ -183,19 +183,19 @@ body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
# Mixed text fields and files
body: |
{{ body_FormData
"title" "My Report"
"author" "John Doe"
"cover" "@/path/to/cover.jpg"
"document" "@/path/to/report.pdf"
}}
{{ body_FormData
"title" "My Report"
"author" "John Doe"
"cover" "@/path/to/cover.jpg"
"document" "@/path/to/report.pdf"
}}
# Multiple files with same field name
body: |
{{ body_FormData
"files" "@/path/to/file1.pdf"
"files" "@/path/to/file2.pdf"
}}
{{ body_FormData
"files" "@/path/to/file1.pdf"
"files" "@/path/to/file2.pdf"
}}
# Escape @ for literal value (sends "@username")
body: '{{ body_FormData "twitter" "@@username" }}'
@@ -226,7 +226,7 @@ body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "docume
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
# Combined with values for reuse
values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
values: 'FILE_DATA={{ file_Base64 "/path/to/file.bin" }}'
body: '{"data": "{{ .Values.FILE_DATA }}"}'
```
@@ -234,7 +234,7 @@ body: '{"data": "{{ .Values.FILE_DATA }}"}'
Captcha functions solve a captcha challenge through a third-party solving service and return the resulting token, which can then be embedded directly into a request. They are intended for load testing endpoints protected by reCAPTCHA, hCaptcha, or Cloudflare Turnstile.
The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required — bring your own key and use any of the supported services per template.
The functions are organized by service: `twocaptcha_*`, `anticaptcha_*`, and `capsolver_*`. Each accepts the API key as the first argument so no global configuration is required. Bring your own key and use any of the supported services per template.
> **Important: performance and cost:**
>
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1784796856,
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+55
View File
@@ -0,0 +1,55 @@
{
description = "Sarin - high-performance HTTP load testing tool built with Go and fasthttp";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
inherit (nixpkgs) lib;
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
rev = self.rev or self.dirtyRev or "unknown";
# self.lastModifiedDate is "YYYYMMDDHHMMSS"; reshape to RFC 3339 (UTC).
raw = self.lastModifiedDate or "19700101000000";
s = i: n: lib.substring i n raw;
buildDate = "${s 0 4}-${s 4 2}-${s 6 2}T${s 8 2}:${s 10 2}:${s 12 2}Z";
in
{
packages = forAllSystems (pkgs: {
default = pkgs.callPackage ./nix/package.nix {
inherit rev buildDate;
};
});
# nix run github:aykhans/sarin -- <args>
apps = forAllSystems (pkgs: {
default = {
type = "app";
program = "${self.packages.${pkgs.system}.default}/bin/sarin";
meta.description = "High-performance HTTP load testing tool";
};
});
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
packages = [ pkgs.go_1_26 pkgs.golangci-lint pkgs.go-task ];
};
});
# For downstream flakes: inputs.sarin.overlays.default
overlays.default = final: _prev: {
sarin = final.callPackage ./nix/package.nix { };
};
};
}
+27 -32
View File
@@ -1,58 +1,53 @@
module go.aykhans.me/sarin
go 1.26.4
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.0
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-20260603143327-1f200ca63355
github.com/dop251/goja v0.0.0-20260806115107-493f22071ef6
github.com/joho/godotenv v1.5.1
github.com/valyala/fasthttp v1.71.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.4
golang.org/x/net v0.55.0
go.yaml.in/yaml/v4 v4.0.0-rc.6
golang.org/x/net v0.58.0
)
require (
github.com/alecthomas/chroma/v2 v2.26.1 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/andybalholm/brotli v1.2.2 // 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-20260602025833-85a30b5e440a // 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.1.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-20260604005048-7023385849c0 // indirect
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.24 // 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.2 // 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/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
)
+58 -72
View File
@@ -1,124 +1,110 @@
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
charm.land/bubbles/v2 v2.2.0 h1:9GEMewcejrNtVIxZ9Y2wSsWJamsWNsXa8MpMLnH4yI4=
charm.land/bubbles/v2 v2.2.0/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=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.26.1 h1:2X21EdxGZNv5GF9mG5u+uzc02GCFyGxbcBm3Grd9A78=
github.com/alecthomas/chroma/v2 v2.26.1/go.mod h1:lxhRRa9H4hPmRLOOdYga4zkQIQjq3dtrrdwQeCfu78Y=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
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.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/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/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
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-20260602025833-85a30b5e440a h1:wjp4RGuKcbK244BiURmgle3N9HMT1ParMh96omH6Lj4=
github.com/charmbracelet/x/exp/slice v0.0.0-20260602025833-85a30b5e440a/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.1.2 h1:RJjAeD/h91npDhTG4rMD4+gzGVnLT32qQy/q/ECbyUs=
github.com/dlclark/regexp2/v2 v2.1.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260603143327-1f200ca63355 h1:QCZbLvvKw3vwkMubwAHHBIecVHyLGfyty0yf5+cx5As=
github.com/dop251/goja v0.0.0-20260603143327-1f200ca63355/go.mod h1:YkABBhNI1qP6/NuyyPL5QqJ4L28ax4m+WAywAWr9fJk=
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-20260604005048-7023385849c0 h1:h1QTMDl6q9wDvDCJVpKQSjgleGFYnd2fOxmg2K+6BGE=
github.com/google/pprof v0.0.0-20260604005048-7023385849c0/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.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
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/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
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.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
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.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/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=
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
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/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=
+22 -7
View File
@@ -27,7 +27,9 @@ Flags:
-c, -concurrency uint Number of concurrent requests (default %d)
-r, -requests uint Number of total requests
-d, -duration time Maximum duration for the test (e.g. 30s, 1m, 5h)
-q, -quiet bool Hide the progress bar and runtime logs (default %v)
-l, -log-level string Runtime log levels to emit, comma-separated (possible values: info, error) (default %s)
-w, -log-file string Write runtime logs to this file instead of the terminal/stderr
-p, -progress string Progress display (possible values: bar, none) (default '%v')
-o, -output string Output format (possible values: table, json, yaml, none) (default '%v')
-z, -dry-run bool Run without sending requests (default %v)
@@ -88,7 +90,9 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
concurrency uint
requestCount uint64
duration time.Duration
quiet bool
logLevel string
logFile string
progress string
output string
dryRun bool
@@ -127,8 +131,14 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
flagSet.DurationVar(&duration, "duration", 0, "Maximum duration for the test")
flagSet.DurationVar(&duration, "d", 0, "Maximum duration for the test")
flagSet.BoolVar(&quiet, "quiet", false, "Hide the progress bar and runtime logs")
flagSet.BoolVar(&quiet, "q", false, "Hide the progress bar and runtime logs")
flagSet.StringVar(&logLevel, "log-level", "", "Runtime log levels to emit, comma-separated (possible values: info, error)")
flagSet.StringVar(&logLevel, "l", "", "Runtime log levels to emit, comma-separated (possible values: info, error)")
flagSet.StringVar(&logFile, "log-file", "", "Write runtime logs to this file instead of the terminal/stderr")
flagSet.StringVar(&logFile, "w", "", "Write runtime logs to this file instead of the terminal/stderr")
flagSet.StringVar(&progress, "progress", "", "Progress display (possible values: bar, none)")
flagSet.StringVar(&progress, "p", "", "Progress display (possible values: bar, none)")
flagSet.StringVar(&output, "output", "", "Output format (possible values: table, json, yaml, none)")
flagSet.StringVar(&output, "o", "", "Output format (possible values: table, json, yaml, none)")
@@ -205,8 +215,12 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
config.Requests = new(requestCount)
case "duration", "d":
config.Duration = new(duration)
case "quiet", "q":
config.Quiet = new(quiet)
case "log-level", "l":
config.LogLevel = new(logLevel)
case "log-file", "w":
config.LogFile = new(logFile)
case "progress", "p":
config.Progress = new(ConfigProgressType(progress))
case "output", "o":
config.Output = new(ConfigOutputType(output))
case "dry-run", "z":
@@ -265,7 +279,8 @@ func (parser ConfigCLIParser) PrintHelp() {
cliUsageText+"\n",
Defaults.ShowConfig,
Defaults.Concurrency,
Defaults.Quiet,
Defaults.LogLevel,
Defaults.Progress,
Defaults.Output,
Defaults.DryRun,
+148 -63
View File
@@ -6,17 +6,18 @@ import (
"fmt"
"net/url"
"os"
"path/filepath"
"slices"
"strconv"
"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"
"go.aykhans.me/sarin/internal/version"
@@ -31,25 +32,28 @@ var Defaults = struct {
RequestTimeout time.Duration
Concurrency uint
ShowConfig bool
Quiet bool
Progress ConfigProgressType
Insecure bool
Output ConfigOutputType
DryRun bool
LogLevel string
}{
UserAgent: "Sarin/" + version.Version,
Method: "GET",
RequestTimeout: time.Second * 10,
Concurrency: 1,
ShowConfig: false,
Quiet: false,
Progress: ConfigProgressTypeBar,
Insecure: false,
Output: ConfigOutputTypeTable,
DryRun: false,
LogLevel: "error",
}
var (
ValidProxySchemes = []string{"http", "https", "socks5", "socks5h"}
ValidRequestURLSchemes = []string{"http", "https"}
ValidLogLevels = []string{"info", "error"}
)
var (
@@ -70,27 +74,36 @@ var (
ConfigOutputTypeNone ConfigOutputType = "none"
)
type ConfigProgressType string
var (
ConfigProgressTypeBar ConfigProgressType = "bar"
ConfigProgressTypeNone ConfigProgressType = "none"
)
type Config struct {
ShowConfig *bool `yaml:"showConfig,omitempty"`
Files []types.ConfigFile `yaml:"files,omitempty"`
Methods []string `yaml:"methods,omitempty"`
URL *url.URL `yaml:"url,omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty"`
Concurrency *uint `yaml:"concurrency,omitempty"`
Requests *uint64 `yaml:"requests,omitempty"`
Duration *time.Duration `yaml:"duration,omitempty"`
Quiet *bool `yaml:"quiet,omitempty"`
Output *ConfigOutputType `yaml:"output,omitempty"`
Insecure *bool `yaml:"insecure,omitempty"`
DryRun *bool `yaml:"dryRun,omitempty"`
Params types.Params `yaml:"params,omitempty"`
Headers types.Headers `yaml:"headers,omitempty"`
Cookies types.Cookies `yaml:"cookies,omitempty"`
Bodies []string `yaml:"bodies,omitempty"`
Proxies types.Proxies `yaml:"proxies,omitempty"`
Values []string `yaml:"values,omitempty"`
Lua []string `yaml:"lua,omitempty"`
Js []string `yaml:"js,omitempty"`
ShowConfig *bool `yaml:"showConfig,omitempty"`
Files []types.ConfigFile `yaml:"files,omitempty"`
Methods []string `yaml:"methods,omitempty"`
URL *url.URL `yaml:"url,omitempty"`
Timeout *time.Duration `yaml:"timeout,omitempty"`
Concurrency *uint `yaml:"concurrency,omitempty"`
Requests *uint64 `yaml:"requests,omitempty"`
Duration *time.Duration `yaml:"duration,omitempty"`
Progress *ConfigProgressType `yaml:"progress,omitempty"`
Output *ConfigOutputType `yaml:"output,omitempty"`
Insecure *bool `yaml:"insecure,omitempty"`
DryRun *bool `yaml:"dryRun,omitempty"`
Params types.Params `yaml:"params,omitempty"`
Headers types.Headers `yaml:"headers,omitempty"`
Cookies types.Cookies `yaml:"cookies,omitempty"`
Bodies []string `yaml:"bodies,omitempty"`
Proxies types.Proxies `yaml:"proxies,omitempty"`
Values []string `yaml:"values,omitempty"`
Lua []string `yaml:"lua,omitempty"`
Js []string `yaml:"js,omitempty"`
LogLevel *string `yaml:"logLevel,omitempty"`
LogFile *string `yaml:"logFile,omitempty"`
}
func (config Config) MarshalYAML() (any, error) {
@@ -173,8 +186,8 @@ func (config Config) MarshalYAML() (any, error) {
if config.Duration != nil {
addField(content, "duration", toNode(*config.Duration), "")
}
if config.Quiet != nil {
addField(content, "quiet", toNode(*config.Quiet), "")
if config.Progress != nil {
addField(content, "progress", toNode(string(*config.Progress)), "")
}
if config.Output != nil {
addField(content, "output", toNode(string(*config.Output)), "")
@@ -221,6 +234,13 @@ func (config Config) MarshalYAML() (any, error) {
addStringSlice(content, "values", config.Values, false)
addStringSlice(content, "lua", config.Lua, false)
addStringSlice(content, "js", config.Js, false)
if config.LogLevel != nil {
addField(content, "logLevel", toNode(*config.LogLevel), "")
}
if config.LogFile != nil {
addField(content, "logFile", toNode(*config.LogFile), "")
}
return root, nil
}
@@ -228,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)
}
@@ -247,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)
}
@@ -295,8 +313,8 @@ func (config *Config) Merge(newConfig *Config) {
if newConfig.ShowConfig != nil {
config.ShowConfig = newConfig.ShowConfig
}
if newConfig.Quiet != nil {
config.Quiet = newConfig.Quiet
if newConfig.Progress != nil {
config.Progress = newConfig.Progress
}
if newConfig.Output != nil {
config.Output = newConfig.Output
@@ -331,6 +349,12 @@ func (config *Config) Merge(newConfig *Config) {
if len(newConfig.Js) != 0 {
config.Js = append(config.Js, newConfig.Js...)
}
if newConfig.LogLevel != nil {
config.LogLevel = newConfig.LogLevel
}
if newConfig.LogFile != nil {
config.LogFile = newConfig.LogFile
}
}
func (config *Config) SetDefaults() {
@@ -361,8 +385,8 @@ func (config *Config) SetDefaults() {
if config.ShowConfig == nil {
config.ShowConfig = new(Defaults.ShowConfig)
}
if config.Quiet == nil {
config.Quiet = new(Defaults.Quiet)
if config.Progress == nil {
config.Progress = new(Defaults.Progress)
}
if config.Insecure == nil {
config.Insecure = new(Defaults.Insecure)
@@ -377,6 +401,14 @@ func (config *Config) SetDefaults() {
if config.Output == nil {
config.Output = new(Defaults.Output)
}
if config.LogLevel == nil {
config.LogLevel = new(Defaults.LogLevel)
}
if config.LogFile == nil {
config.LogFile = new("")
}
}
// Validate validates the config fields.
@@ -426,8 +458,21 @@ func (config Config) Validate() error {
validationErrors = append(validationErrors, types.NewFieldValidationError("ShowConfig", "", errors.New("showConfig field is required")))
}
if config.Quiet == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Quiet", "", errors.New("quiet field is required")))
if config.Progress == nil {
validationErrors = append(validationErrors, types.NewFieldValidationError("Progress", "", errors.New("progress field is required")))
} else {
switch *config.Progress {
case ConfigProgressTypeBar, ConfigProgressTypeNone:
default:
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
"Progress",
string(*config.Progress),
fmt.Errorf("progress must be one of: %s, %s", ConfigProgressTypeBar, ConfigProgressTypeNone),
),
)
}
}
if config.Output == nil {
@@ -460,6 +505,36 @@ func (config Config) Validate() error {
validationErrors = append(validationErrors, types.NewFieldValidationError("DryRun", "", errors.New("dryRun field is required")))
}
if config.LogLevel != nil {
for i, level := range sarin.SplitLogLevels(*config.LogLevel) {
if !slices.Contains(ValidLogLevels, level) {
validationErrors = append(
validationErrors,
types.NewFieldValidationError(
fmt.Sprintf("LogLevel[%d]", i),
level,
fmt.Errorf("log level must be one of: %s", strings.Join(ValidLogLevels, ", ")),
),
)
}
}
}
if config.LogFile != nil && *config.LogFile != "" {
dir := filepath.Dir(*config.LogFile)
if info, err := os.Stat(dir); err != nil {
validationErrors = append(
validationErrors,
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent directory %q is not accessible", dir)),
)
} else if !info.IsDir() {
validationErrors = append(
validationErrors,
types.NewFieldValidationError("LogFile", *config.LogFile, fmt.Errorf("parent path %q is not a directory", dir)),
)
}
}
for i, proxy := range config.Proxies {
if !slices.Contains(ValidProxySchemes, proxy.Scheme) {
validationErrors = append(
@@ -538,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
}),
@@ -560,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
}),
@@ -676,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(),
)
))
}
}
}
@@ -723,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
@@ -746,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) {
@@ -772,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)
}
}
@@ -792,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 {
@@ -819,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()
+74 -78
View File
@@ -49,74 +49,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
}
if quiet := parser.getEnv("QUIET"); quiet != "" {
quietParsed, err := utilsParse.ParseString[bool](quiet)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("QUIET"),
quiet,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Quiet = &quietParsed
}
}
if output := parser.getEnv("OUTPUT"); output != "" {
config.Output = new(ConfigOutputType(output))
}
if insecure := parser.getEnv("INSECURE"); insecure != "" {
insecureParsed, err := utilsParse.ParseString[bool](insecure)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("INSECURE"),
insecure,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Insecure = &insecureParsed
}
}
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("DRY_RUN"),
dryRun,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.DryRun = &dryRunParsed
}
}
if method := parser.getEnv("METHOD"); method != "" {
config.Methods = []string{method}
}
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
urlEnvParsed, err := url.Parse(urlEnv)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
)
} else {
config.URL = urlEnvParsed
}
}
if concurrency := parser.getEnv("CONCURRENCY"); concurrency != "" {
concurrencyParsed, err := utilsParse.ParseString[uint](concurrency)
if err != nil {
@@ -165,22 +97,58 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
}
}
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
if logLevel := parser.getEnv("LOG_LEVEL"); logLevel != "" {
config.LogLevel = new(logLevel)
}
if logFile := parser.getEnv("LOG_FILE"); logFile != "" {
config.LogFile = new(logFile)
}
if progress := parser.getEnv("PROGRESS"); progress != "" {
config.Progress = new(ConfigProgressType(progress))
}
if output := parser.getEnv("OUTPUT"); output != "" {
config.Output = new(ConfigOutputType(output))
}
if dryRun := parser.getEnv("DRY_RUN"); dryRun != "" {
dryRunParsed, err := utilsParse.ParseString[bool](dryRun)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("TIMEOUT"),
timeout,
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
parser.getFullEnvName("DRY_RUN"),
dryRun,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Timeout = &timeoutParsed
config.DryRun = &dryRunParsed
}
}
if urlEnv := parser.getEnv("URL"); urlEnv != "" {
urlEnvParsed, err := url.Parse(urlEnv)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(parser.getFullEnvName("URL"), urlEnv, err),
)
} else {
config.URL = urlEnvParsed
}
}
if method := parser.getEnv("METHOD"); method != "" {
config.Methods = []string{method}
}
if body := parser.getEnv("BODY"); body != "" {
config.Bodies = []string{body}
}
if param := parser.getEnv("PARAM"); param != "" {
config.Params.Parse(param)
}
@@ -193,10 +161,6 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Cookies.Parse(cookie)
}
if body := parser.getEnv("BODY"); body != "" {
config.Bodies = []string{body}
}
if proxy := parser.getEnv("PROXY"); proxy != "" {
err := config.Proxies.Parse(proxy)
if err != nil {
@@ -215,6 +179,38 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
config.Values = []string{values}
}
if timeout := parser.getEnv("TIMEOUT"); timeout != "" {
timeoutParsed, err := utilsParse.ParseString[time.Duration](timeout)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("TIMEOUT"),
timeout,
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
),
)
} else {
config.Timeout = &timeoutParsed
}
}
if insecure := parser.getEnv("INSECURE"); insecure != "" {
insecureParsed, err := utilsParse.ParseString[bool](insecure)
if err != nil {
fieldParseErrors = append(
fieldParseErrors,
types.NewFieldParseError(
parser.getFullEnvName("INSECURE"),
insecure,
errors.New("invalid value for boolean, expected 'true' or 'false'"),
),
)
} else {
config.Insecure = &insecureParsed
}
}
if lua := parser.getEnv("LUA"); lua != "" {
config.Lua = []string{lua}
}
+43 -33
View File
@@ -192,24 +192,26 @@ func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
}
type configYAML struct {
ShowConfig *bool `yaml:"showConfig"`
ConfigFiles stringOrSliceField `yaml:"configFile"`
Method stringOrSliceField `yaml:"method"`
URL *string `yaml:"url"`
Timeout *time.Duration `yaml:"timeout"`
Concurrency *uint `yaml:"concurrency"`
RequestCount *uint64 `yaml:"requests"`
Duration *time.Duration `yaml:"duration"`
Quiet *bool `yaml:"quiet"`
LogLevel *string `yaml:"logLevel"`
LogFile *string `yaml:"logFile"`
Progress *string `yaml:"progress"`
Output *string `yaml:"output"`
Insecure *bool `yaml:"insecure"`
ShowConfig *bool `yaml:"showConfig"`
DryRun *bool `yaml:"dryRun"`
URL *string `yaml:"url"`
Method stringOrSliceField `yaml:"method"`
Bodies stringOrSliceField `yaml:"body"`
Params keyValuesField `yaml:"params"`
Headers keyValuesField `yaml:"headers"`
Cookies keyValuesField `yaml:"cookies"`
Bodies stringOrSliceField `yaml:"body"`
Proxies stringOrSliceField `yaml:"proxy"`
Values stringOrSliceField `yaml:"values"`
Timeout *time.Duration `yaml:"timeout"`
Insecure *bool `yaml:"insecure"`
Lua stringOrSliceField `yaml:"lua"`
Js stringOrSliceField `yaml:"js"`
}
@@ -231,33 +233,7 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
var fieldParseErrors []types.FieldParseError
config.Methods = append(config.Methods, parsedData.Method...)
config.Timeout = parsedData.Timeout
config.Concurrency = parsedData.Concurrency
config.Requests = parsedData.RequestCount
config.Duration = parsedData.Duration
config.ShowConfig = parsedData.ShowConfig
config.Quiet = parsedData.Quiet
if parsedData.Output != nil {
config.Output = new(ConfigOutputType(*parsedData.Output))
}
config.Insecure = parsedData.Insecure
config.DryRun = parsedData.DryRun
for _, kv := range parsedData.Params {
config.Params = append(config.Params, types.Param(kv))
}
for _, kv := range parsedData.Headers {
config.Headers = append(config.Headers, types.Header(kv))
}
for _, kv := range parsedData.Cookies {
config.Cookies = append(config.Cookies, types.Cookie(kv))
}
config.Bodies = append(config.Bodies, parsedData.Bodies...)
config.Values = append(config.Values, parsedData.Values...)
config.Lua = append(config.Lua, parsedData.Lua...)
config.Js = append(config.Js, parsedData.Js...)
if len(parsedData.ConfigFiles) > 0 {
for _, configFile := range parsedData.ConfigFiles {
@@ -265,6 +241,22 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
}
}
config.Concurrency = parsedData.Concurrency
config.Requests = parsedData.RequestCount
config.Duration = parsedData.Duration
config.LogLevel = parsedData.LogLevel
config.LogFile = parsedData.LogFile
if parsedData.Progress != nil {
config.Progress = new(ConfigProgressType(*parsedData.Progress))
}
if parsedData.Output != nil {
config.Output = new(ConfigOutputType(*parsedData.Output))
}
config.DryRun = parsedData.DryRun
if parsedData.URL != nil {
urlParsed, err := url.Parse(*parsedData.URL)
if err != nil {
@@ -274,6 +266,18 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
}
}
config.Methods = append(config.Methods, parsedData.Method...)
config.Bodies = append(config.Bodies, parsedData.Bodies...)
for _, kv := range parsedData.Params {
config.Params = append(config.Params, types.Param(kv))
}
for _, kv := range parsedData.Headers {
config.Headers = append(config.Headers, types.Header(kv))
}
for _, kv := range parsedData.Cookies {
config.Cookies = append(config.Cookies, types.Cookie(kv))
}
for i, proxy := range parsedData.Proxies {
err := config.Proxies.Parse(proxy)
if err != nil {
@@ -284,6 +288,12 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
}
}
config.Values = append(config.Values, parsedData.Values...)
config.Timeout = parsedData.Timeout
config.Insecure = parsedData.Insecure
config.Lua = append(config.Lua, parsedData.Lua...)
config.Js = append(config.Js, parsedData.Js...)
if len(fieldParseErrors) > 0 {
return nil, types.NewFieldParseErrors(fieldParseErrors)
}
+85 -33
View File
@@ -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(
+3 -4
View File
@@ -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() {
+281 -51
View File
@@ -2,8 +2,13 @@ package sarin
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
@@ -14,20 +19,165 @@ import (
"go.aykhans.me/sarin/internal/types"
)
type runtimeMessageLevel uint8
type runtimeLogLevel uint8
const (
runtimeMessageLevelWarning runtimeMessageLevel = iota
runtimeMessageLevelError
runtimeLogLevelInfo runtimeLogLevel = iota
runtimeLogLevelError
)
type runtimeMessage struct {
type runtimeLog struct {
timestamp time.Time
level runtimeMessageLevel
level runtimeLogLevel
text string
}
type messageSender func(level runtimeMessageLevel, text string)
type runtimeLogger func(level runtimeLogLevel, text string)
// SplitLogLevels parses a comma-separated log-level string into a deduplicated
// slice of level tokens (surrounding whitespace trimmed, empties dropped). Level
// names are matched case-sensitively, like the other enum options.
func SplitLogLevels(levels string) []string {
var out []string
seen := make(map[string]bool)
for part := range strings.SplitSeq(levels, ",") {
token := strings.TrimSpace(part)
if token == "" || seen[token] {
continue
}
seen[token] = true
out = append(out, token)
}
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)
func noopLog(runtimeLogLevel, string) {}
func noopRespLog(time.Duration, *fasthttp.Response) {}
// gateSendLog wraps emit with level filtering decided once from the enabled
// levels. It stays general (any level filters correctly) while avoiding a
// per-log check when both levels are on and any work at all when both are off.
func gateSendLog(logInfo, logError bool, emit func(level runtimeLogLevel, text string)) runtimeLogger {
switch {
case logInfo && logError:
return emit
case logInfo:
return func(level runtimeLogLevel, text string) {
if level == runtimeLogLevelInfo {
emit(level, text)
}
}
case logError:
return func(level runtimeLogLevel, text string) {
if level == runtimeLogLevelError {
emit(level, text)
}
}
default:
return noopLog
}
}
// respBodySnippetLen bounds how many bytes of the body the compact (TUI)
// rendering shows.
const respBodySnippetLen = 100
// formatRuntimeLogLine renders a runtime log as a plain (unstyled) line for
// stderr output. ANSI styling is left to the TUI.
func formatRuntimeLogLine(timestamp time.Time, level runtimeLogLevel, text string) string {
levelStr := "ERROR"
if level == runtimeLogLevelInfo {
levelStr = "INFO"
}
return "[" + timestamp.Format("15:04:05") + "] " + levelStr + ": " + text
}
// respToLog renders a response as a compact one-line summary for the TUI log box
// (the "[time] INFO:" prefix is added by the TUI).
func respToLog(duration time.Duration, resp *fasthttp.Response) string {
var sb strings.Builder
sb.WriteString(statusCodeToString(resp.StatusCode()))
sb.WriteString(" ")
sb.WriteString(Duration(duration).String())
if snippet := bodySnippet(resp.Body(), respBodySnippetLen); snippet != "" {
sb.WriteString(" | ")
sb.WriteString(snippet)
}
return sb.String()
}
type respLogEntry struct {
Status int `json:"status"`
Duration string `json:"duration"`
Headers map[string][]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
}
// respToLogJSON renders a response as a single, self-contained JSON line so the
// stderr stream stays valid NDJSON (pipeable to jq or any consumer).
func respToLogJSON(duration time.Duration, resp *fasthttp.Response) string {
entry := respLogEntry{
Status: resp.StatusCode(),
Duration: Duration(duration).String(),
Headers: collectRespHeaders(resp),
Body: string(resp.Body()),
}
data, err := json.Marshal(entry)
if err != nil {
return `{"error":"failed to marshal response log"}`
}
return string(data)
}
func collectRespHeaders(resp *fasthttp.Response) map[string][]string {
headers := make(map[string][]string)
for key, value := range resp.Header.All() {
k := string(key)
headers[k] = append(headers[k], string(value))
}
return headers
}
// bodySnippet returns the first maxLen bytes of body collapsed onto a single
// line (control characters replaced with spaces), with an ellipsis when truncated.
func bodySnippet(body []byte, maxLen int) string {
if len(body) == 0 {
return ""
}
truncated := false
if len(body) > maxLen {
body = body[:maxLen]
truncated = true
}
s := strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' {
return ' '
}
return r
}, string(body))
if truncated {
s += "..."
}
return s
}
type sarin struct {
workers uint
@@ -40,11 +190,14 @@ type sarin struct {
totalRequests *uint64
totalDuration *time.Duration
timeout time.Duration
quiet bool
showProgress bool
skipCertVerify bool
values []string
collectStats bool
dryRun bool
logInfo bool
logError bool
logFile string
hostClients []*fasthttp.HostClient
responses *SarinResponseData
@@ -65,7 +218,7 @@ func NewSarin(
workers uint,
totalRequests *uint64,
totalDuration *time.Duration,
quiet bool,
showProgress bool,
skipCertVerify bool,
params types.Params,
headers types.Headers,
@@ -75,6 +228,8 @@ func NewSarin(
values []string,
collectStats bool,
dryRun bool,
logLevel string,
logFile string,
luaScripts []string,
jsScripts []string,
) (*sarin, error) {
@@ -82,6 +237,17 @@ func NewSarin(
workers = 1
}
// Resolve which log levels are enabled once, up front.
var logInfo, logError bool
for _, level := range SplitLogLevels(logLevel) {
switch level {
case "info":
logInfo = true
case "error":
logError = true
}
}
hostClients, err := newHostClients(ctx, timeout, proxies, workers, requestURL, skipCertVerify)
if err != nil {
return nil, err
@@ -111,11 +277,14 @@ func NewSarin(
totalRequests: totalRequests,
totalDuration: totalDuration,
timeout: timeout,
quiet: quiet,
showProgress: showProgress,
skipCertVerify: skipCertVerify,
values: values,
collectStats: collectStats,
dryRun: dryRun,
logInfo: logInfo,
logError: logError,
logFile: logFile,
hostClients: hostClients,
fileCache: NewFileCache(time.Second * 10),
scriptChain: scriptChain,
@@ -128,73 +297,95 @@ func NewSarin(
return srn, nil
}
func (q sarin) GetResponses() *SarinResponseData {
return q.responses
func (s sarin) GetResponses() *SarinResponseData {
return s.responses
}
func (q sarin) Start(ctx context.Context, stopCtrl *StopController) {
func (s sarin) Start(ctx context.Context, stopCtrl *StopController) {
jobsCtx, jobsCancel := context.WithCancel(ctx)
var workersWG sync.WaitGroup
jobsCh := make(chan struct{}, max(q.workers, 1))
jobsCh := make(chan struct{}, max(s.workers, 1))
var counter atomic.Uint64
totalRequests := uint64(0)
if q.totalRequests != nil {
totalRequests = *q.totalRequests
if s.totalRequests != nil {
totalRequests = *s.totalRequests
}
var streamCtx context.Context
var streamCancel context.CancelFunc
var streamCh chan struct{}
var messageChannel chan runtimeMessage
var sendMessage messageSender
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
// either has something to show: the bar, or logs that would land in the box
// (i.e. logs are enabled and not redirected to a file).
runTUI := showProgressBar || (onTerminal && (s.logInfo || s.logError) && s.logFile == "")
if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) {
q.quiet = true
// Open the log file up front, before registering any defer, so a bad path
// exits cleanly.
var logFile *os.File
if s.logFile != "" {
f, err := os.OpenFile(s.logFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
fmt.Fprintln(os.Stderr, "failed to open log file "+s.logFile+": "+err.Error())
os.Exit(1)
}
defer f.Close() //nolint:errcheck
logFile = f
}
if q.quiet {
sendMessage = func(level runtimeMessageLevel, text string) {}
} else {
var (
streamCtx context.Context
streamCancel context.CancelFunc
streamCh chan struct{}
tuiLogChannel chan runtimeLog
)
if runTUI {
streamCtx, streamCancel = context.WithCancel(context.Background())
defer streamCancel()
streamCh = make(chan struct{})
messageChannel = make(chan runtimeMessage, max(q.workers, 1))
sendMessage = func(level runtimeMessageLevel, text string) {
messageChannel <- runtimeMessage{
timestamp: time.Now(),
level: level,
text: text,
}
}
tuiLogChannel = make(chan runtimeLog, max(s.workers, 1))
}
// Route logs: to the file if given, else to the TUI log box while it runs,
// otherwise to stderr.
var (
sendLog runtimeLogger
sendRespLog respLogger
)
switch {
case logFile != nil:
sendLog, sendRespLog = s.newWriterLog(logFile)
case runTUI:
sendLog, sendRespLog = s.newChannelLog(tuiLogChannel)
default:
sendLog, sendRespLog = s.newWriterLog(os.Stderr)
}
// Start workers
q.startWorkers(&workersWG, jobsCh, q.hostClients, &counter, sendMessage)
s.startWorkers(&workersWG, jobsCh, s.hostClients, &counter, sendLog, sendRespLog)
if !q.quiet {
// Start streaming to terminal
if runTUI {
//nolint:contextcheck // streamCtx must remain active until all workers complete to ensure all collected data is streamed
go q.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, messageChannel)
go s.streamProgress(streamCtx, stopCtrl, streamCh, totalRequests, &counter, tuiLogChannel, showProgressBar)
}
// Setup duration-based cancellation
q.setupDurationTimeout(ctx, jobsCancel)
s.setupDurationTimeout(ctx, jobsCancel)
// Distribute jobs to workers.
// This blocks until all jobs are sent or the context is canceled.
q.sendJobs(jobsCtx, jobsCh)
s.sendJobs(jobsCtx, jobsCh)
// Close the jobs channel so workers stop after completing their current job
close(jobsCh)
// Wait until all workers stopped
workersWG.Wait()
if messageChannel != nil {
close(messageChannel)
if tuiLogChannel != nil {
close(tuiLogChannel)
}
if !q.quiet {
if runTUI {
// Stop the progress streaming
streamCancel()
// Wait until progress streaming has completely stopped
@@ -202,6 +393,45 @@ func (q sarin) Start(ctx context.Context, stopCtrl *StopController) {
}
}
// newWriterLog builds the loggers that write formatted lines to w (a log file or
// stderr). sendLog stays general (it filters by each log's level); sendRespLog
// only ever emits info, so its decision is baked once into a no-op when off.
func (s sarin) newWriterLog(w io.Writer) (runtimeLogger, respLogger) {
// log.Logger serializes writes with its own mutex, so concurrent workers
// won't interleave lines.
logger := log.New(w, "", 0)
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
logger.Println(formatRuntimeLogLine(time.Now(), level, text))
})
var sendRespLog respLogger = noopRespLog
if s.logInfo {
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
logger.Println(formatRuntimeLogLine(time.Now(), runtimeLogLevelInfo, respToLogJSON(duration, resp)))
}
}
return sendLog, sendRespLog
}
// newChannelLog builds the loggers that feed the TUI log box through ch, with the
// same gating as newWriterLog.
func (s sarin) newChannelLog(ch chan<- runtimeLog) (runtimeLogger, respLogger) {
sendLog := gateSendLog(s.logInfo, s.logError, func(level runtimeLogLevel, text string) {
ch <- runtimeLog{timestamp: time.Now(), level: level, text: text}
})
var sendRespLog respLogger = noopRespLog
if s.logInfo {
sendRespLog = func(duration time.Duration, resp *fasthttp.Response) {
ch <- runtimeLog{timestamp: time.Now(), level: runtimeLogLevelInfo, text: respToLog(duration, resp)}
}
}
return sendLog, sendRespLog
}
// newHostClients initializes HTTP clients for the given configuration.
// It can return the following errors:
// - types.ProxyDialError
@@ -228,18 +458,18 @@ func newHostClients(
)
}
func (q sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendMessage messageSender) {
for range max(q.workers, 1) {
func (s sarin) startWorkers(wg *sync.WaitGroup, jobs <-chan struct{}, hostClients []*fasthttp.HostClient, counter *atomic.Uint64, sendLog runtimeLogger, sendRespLog respLogger) {
for range max(s.workers, 1) {
wg.Go(func() {
q.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendMessage)
s.Worker(jobs, NewHostClientGenerator(hostClients...), counter, sendLog, sendRespLog)
})
}
}
func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
if q.totalDuration != nil {
func (s sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFunc) {
if s.totalDuration != nil {
go func() {
timer := time.NewTimer(*q.totalDuration)
timer := time.NewTimer(*s.totalDuration)
defer timer.Stop()
select {
case <-timer.C:
@@ -251,9 +481,9 @@ func (q sarin) setupDurationTimeout(ctx context.Context, cancel context.CancelFu
}
}
func (q sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
if q.totalRequests != nil && *q.totalRequests > 0 {
for range *q.totalRequests {
func (s sarin) sendJobs(ctx context.Context, jobs chan<- struct{}) {
if s.totalRequests != nil && *s.totalRequests > 0 {
for range *s.totalRequests {
if ctx.Err() != nil {
break
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"sync"
"sync/atomic"
tea "github.com/charmbracelet/bubbletea"
tea "charm.land/bubbletea/v2"
)
const forceExitCode = 130
+162 -116
View File
@@ -2,38 +2,86 @@ 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
var (
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#d1d1d1"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FC5B5B")).Bold(true)
warningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")).Bold(true)
messageChannelStyle = lipgloss.NewStyle().
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#757575")).
PaddingLeft(1).
Margin(1, 0, 0, 0).
Foreground(lipgloss.Color("#888888"))
// 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)
infoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#5BC0FC")).Bold(true)
errorLabel = errorStyle.Render("ERROR: ")
infoLabel = infoStyle.Render("INFO: ")
logChannelStyle = lipgloss.NewStyle().
Border(lipgloss.ThickBorder(), false, false, false, true).
BorderForeground(lipgloss.Color("#757575")).
PaddingLeft(1).
Margin(1, 0, 0, 0).
Foreground(lipgloss.Color("#888888"))
)
// renderRuntimeLog builds a single styled log line for the TUI log box.
func renderRuntimeLog(log runtimeLog) string {
label := errorLabel
if log.level == runtimeLogLevelInfo {
label = infoLabel
}
return "[" + log.timestamp.Format("15:04:05") + "] " + label + log.text
}
// renderLogBox renders the styled log box, or "" when there are no lines.
func renderLogBox(logs []string) string {
var b strings.Builder
for i, line := range logs {
if len(line) > 0 {
b.WriteString(line)
if i < len(logs)-1 {
b.WriteString("\n")
}
}
}
if b.Len() == 0 {
return ""
}
return logChannelStyle.Render(b.String())
}
func helpLine(cancelling bool) string {
if cancelling {
return helpStyle.Render("Stopping... (Ctrl+C again to force)")
}
return helpStyle.Render("Press Ctrl+C to quit")
}
type progressModel struct {
progress progress.Model
startTime time.Time
messages []string
logs []string
counter *atomic.Uint64
current uint64
maxValue uint64
showBar bool
ctx context.Context //nolint:containedctx
stop func()
cancelling bool
@@ -45,33 +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 runtimeMessage:
var msgBuilder strings.Builder
msgBuilder.WriteString("[")
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
msgBuilder.WriteString("] ")
switch msg.level {
case runtimeMessageLevelError:
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
case runtimeMessageLevelWarning:
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
case logBatch:
for _, entry := range msg {
m.logs = append(m.logs[1:], renderRuntimeLog(entry))
}
msgBuilder.WriteString(msg.text)
m.messages = append(m.messages[1:], msgBuilder.String())
if m.ctx.Err() != nil {
return m, tea.Quit
}
@@ -91,39 +130,28 @@ func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
func (m progressModel) View() string {
var messagesBuilder strings.Builder
for i, msg := range m.messages {
if len(msg) > 0 {
messagesBuilder.WriteString(msg)
if i < len(m.messages)-1 {
messagesBuilder.WriteString("\n")
}
}
func (m progressModel) View() tea.View {
var b strings.Builder
if box := renderLogBox(m.logs); box != "" {
b.WriteString(box)
b.WriteString("\n")
}
var finalBuilder strings.Builder
if messagesBuilder.Len() > 0 {
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
finalBuilder.WriteString("\n")
if m.showBar {
current := m.counter.Load()
b.WriteString("\n ")
b.WriteString(strconv.FormatUint(current, 10))
b.WriteString("/")
b.WriteString(strconv.FormatUint(m.maxValue, 10))
b.WriteString(" - ")
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
b.WriteString("\n ")
b.WriteString(m.progress.ViewAs(float64(current) / float64(m.maxValue)))
}
m.current = m.counter.Load()
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.current, 10))
finalBuilder.WriteString("/")
finalBuilder.WriteString(strconv.FormatUint(m.maxValue, 10))
finalBuilder.WriteString(" - ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(m.progress.ViewAs(float64(m.current) / float64(m.maxValue)))
finalBuilder.WriteString("\n\n ")
if m.cancelling {
finalBuilder.WriteString(helpStyle.Render("Stopping... (Ctrl+C again to force)"))
} else {
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
}
return finalBuilder.String()
b.WriteString("\n\n ")
b.WriteString(helpLine(m.cancelling))
return tea.NewView(b.String())
}
func progressTickCmd() tea.Cmd {
@@ -138,7 +166,8 @@ type infiniteProgressModel struct {
spinner spinner.Model
startTime time.Time
counter *atomic.Uint64
messages []string
logs []string
showBar bool
ctx context.Context //nolint:containedctx
quit bool
stop func()
@@ -151,26 +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 runtimeMessage:
var msgBuilder strings.Builder
msgBuilder.WriteString("[")
msgBuilder.WriteString(msg.timestamp.Format("15:04:05"))
msgBuilder.WriteString("] ")
switch msg.level {
case runtimeMessageLevelError:
msgBuilder.WriteString(errorStyle.Render("ERROR: "))
case runtimeMessageLevelWarning:
msgBuilder.WriteString(warningStyle.Render("WARNING: "))
case logBatch:
for _, entry := range msg {
m.logs = append(m.logs[1:], renderRuntimeLog(entry))
}
msgBuilder.WriteString(msg.text)
m.messages = append(m.messages[1:], msgBuilder.String())
if m.ctx.Err() != nil {
m.quit = true
return m, tea.Quit
@@ -188,65 +208,64 @@ func (m infiniteProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
func (m infiniteProgressModel) View() string {
var messagesBuilder strings.Builder
for i, msg := range m.messages {
if len(msg) > 0 {
messagesBuilder.WriteString(msg)
if i < len(m.messages)-1 {
messagesBuilder.WriteString("\n")
}
}
func (m infiniteProgressModel) View() tea.View {
var b strings.Builder
if box := renderLogBox(m.logs); box != "" {
b.WriteString(box)
b.WriteString("\n")
}
var finalBuilder strings.Builder
if messagesBuilder.Len() > 0 {
finalBuilder.WriteString(messageChannelStyle.Render(messagesBuilder.String()))
finalBuilder.WriteString("\n")
// Without a spinner, the view is just the log box (plus help until quit).
if !m.showBar {
if !m.quit {
b.WriteString("\n\n ")
b.WriteString(helpLine(m.cancelling))
}
return tea.NewView(b.String())
}
if m.quit {
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n\n")
b.WriteString("\n ")
b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
b.WriteString(" ")
b.WriteString(infiniteProgressStyle.Render("∙∙∙∙∙"))
b.WriteString(" ")
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
b.WriteString("\n\n")
} else {
finalBuilder.WriteString("\n ")
finalBuilder.WriteString(strconv.FormatUint(m.counter.Load(), 10))
finalBuilder.WriteString(" ")
finalBuilder.WriteString(m.spinner.View())
finalBuilder.WriteString(" ")
finalBuilder.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
finalBuilder.WriteString("\n\n ")
if m.cancelling {
finalBuilder.WriteString(helpStyle.Render("Stopping... (Ctrl+C again to force)"))
} else {
finalBuilder.WriteString(helpStyle.Render("Press Ctrl+C to quit"))
}
b.WriteString("\n ")
b.WriteString(strconv.FormatUint(m.counter.Load(), 10))
b.WriteString(" ")
b.WriteString(m.spinner.View())
b.WriteString(" ")
b.WriteString(time.Since(m.startTime).Round(time.Second / 10).String())
b.WriteString("\n\n ")
b.WriteString(helpLine(m.cancelling))
}
return finalBuilder.String()
return tea.NewView(b.String())
}
func (q sarin) streamProgress(
func (s sarin) streamProgress(
ctx context.Context,
stopCtrl *StopController,
done chan<- struct{},
total uint64,
counter *atomic.Uint64,
messageChannel <-chan runtimeMessage,
logChannel <-chan runtimeLog,
showBar bool,
) {
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(),
messages: make([]string, 8),
logs: make([]string, logBoxLines),
counter: counter,
current: 0,
maxValue: total,
showBar: showBar,
ctx: ctx,
stop: stopCtrl.Stop,
}
@@ -274,7 +293,8 @@ func (q sarin) streamProgress(
),
startTime: time.Now(),
counter: counter,
messages: make([]string, 8),
logs: make([]string, logBoxLines),
showBar: showBar,
ctx: ctx,
stop: stopCtrl.Stop,
quit: false,
@@ -286,9 +306,35 @@ func (q 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 messageChannel {
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()
}
}
}()
+84 -71
View File
@@ -12,28 +12,29 @@ 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)
}
func (q sarin) Worker(
func (s sarin) Worker(
jobs <-chan struct{},
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
@@ -43,9 +44,9 @@ func (q sarin) Worker(
// Create script transformer for this worker (engines are not thread-safe)
// Scripts are pre-validated in NewSarin, so this should not fail
var scriptTransformer *script.Transformer
if !q.scriptChain.IsEmpty() {
if !s.scriptChain.IsEmpty() {
var err error
scriptTransformer, err = q.scriptChain.NewTransformer()
scriptTransformer, err = s.scriptChain.NewTransformer()
if err != nil {
panic(err)
}
@@ -53,131 +54,140 @@ func (q sarin) Worker(
}
requestGenerator, isDynamic := NewRequestGenerator(
q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer,
s.methods, s.requestURL, s.params, s.headers, s.cookies, s.bodies, s.values, s.fileCache, scriptTransformer,
)
if q.dryRun {
if s.dryRun {
switch {
case q.collectStats && isDynamic:
q.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
case q.collectStats && !isDynamic:
q.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
case !q.collectStats && isDynamic:
q.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendMessage)
case s.collectStats && isDynamic:
s.workerDryRunStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
case s.collectStats && !isDynamic:
s.workerDryRunStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
case !s.collectStats && isDynamic:
s.workerDryRunNoStatsWithDynamic(jobs, req, requestGenerator, counter, sendLog)
default:
q.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendMessage)
s.workerDryRunNoStatsWithStatic(jobs, req, requestGenerator, counter, sendLog)
}
} else {
switch {
case q.collectStats && isDynamic:
q.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
case q.collectStats && !isDynamic:
q.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
case !q.collectStats && isDynamic:
q.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
case s.collectStats && isDynamic:
s.workerStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
case s.collectStats && !isDynamic:
s.workerStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
case !s.collectStats && isDynamic:
s.workerNoStatsWithDynamic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
default:
q.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendMessage)
s.workerNoStatsWithStatic(jobs, req, resp, requestGenerator, hostClientGenerator, counter, sendLog, sendRespLog)
}
}
}
func (q sarin) workerStatsWithDynamic(
func (s sarin) workerStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
for range jobs {
req.Reset()
resp.Reset()
if err := requestGenerator(req); err != nil {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
s.responses.Add(err.Error(), 0)
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
continue
}
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
respDuration := time.Since(startTime)
if err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
s.responses.Add(err.Error(), respDuration)
} else {
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
sendRespLog(respDuration, resp)
}
counter.Add(1)
}
}
func (q sarin) workerStatsWithStatic(
func (s sarin) workerStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors
for range jobs {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
s.responses.Add(err.Error(), 0)
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
}
return
}
for range jobs {
resp.Reset()
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, q.timeout)
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
respDuration := time.Since(startTime)
if err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
s.responses.Add(err.Error(), respDuration)
} else {
q.responses.Add(statusCodeToString(resp.StatusCode()), time.Since(startTime))
s.responses.Add(statusCodeToString(resp.StatusCode()), respDuration)
sendRespLog(respDuration, resp)
}
counter.Add(1)
}
}
func (q sarin) workerNoStatsWithDynamic(
func (s sarin) workerNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
for range jobs {
req.Reset()
resp.Reset()
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
continue
}
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
if err == nil {
sendRespLog(time.Since(startTime), resp)
}
counter.Add(1)
}
}
func (q sarin) workerNoStatsWithStatic(
func (s sarin) workerNoStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
resp *fasthttp.Response,
requestGenerator RequestGenerator,
hostClientGenerator HostClientGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
sendLog runtimeLogger,
sendRespLog respLogger,
) {
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
sendLog(runtimeLogLevelError, err.Error())
// Static request generation failed - just count the jobs without sending
for range jobs {
@@ -187,81 +197,84 @@ func (q sarin) workerNoStatsWithStatic(
}
for range jobs {
resp.Reset()
_ = hostClientGenerator().DoTimeout(req, resp, q.timeout)
startTime := time.Now()
err := hostClientGenerator().DoTimeout(req, resp, s.timeout)
if err == nil {
sendRespLog(time.Since(startTime), resp)
}
counter.Add(1)
}
}
func (q sarin) workerDryRunStatsWithDynamic(
func (s sarin) workerDryRunStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
sendLog runtimeLogger,
) {
for range jobs {
req.Reset()
startTime := time.Now()
if err := requestGenerator(req); err != nil {
q.responses.Add(err.Error(), time.Since(startTime))
sendMessage(runtimeMessageLevelError, err.Error())
s.responses.Add(err.Error(), time.Since(startTime))
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
continue
}
q.responses.Add(dryRunResponseKey, time.Since(startTime))
s.responses.Add(dryRunResponseKey, time.Since(startTime))
counter.Add(1)
}
}
func (q sarin) workerDryRunStatsWithStatic(
func (s sarin) workerDryRunStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
sendLog runtimeLogger,
) {
if err := requestGenerator(req); err != nil {
// Static request generation failed - record all jobs as errors
for range jobs {
q.responses.Add(err.Error(), 0)
sendMessage(runtimeMessageLevelError, err.Error())
s.responses.Add(err.Error(), 0)
sendLog(runtimeLogLevelError, err.Error())
counter.Add(1)
}
return
}
for range jobs {
q.responses.Add(dryRunResponseKey, 0)
s.responses.Add(dryRunResponseKey, 0)
counter.Add(1)
}
}
func (q sarin) workerDryRunNoStatsWithDynamic(
func (s sarin) workerDryRunNoStatsWithDynamic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
sendLog runtimeLogger,
) {
for range jobs {
req.Reset()
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
sendLog(runtimeLogLevelError, err.Error())
}
counter.Add(1)
}
}
func (q sarin) workerDryRunNoStatsWithStatic(
func (s sarin) workerDryRunNoStatsWithStatic(
jobs <-chan struct{},
req *fasthttp.Request,
requestGenerator RequestGenerator,
counter *atomic.Uint64,
sendMessage messageSender,
sendLog runtimeLogger,
) {
if err := requestGenerator(req); err != nil {
sendMessage(runtimeMessageLevelError, err.Error())
sendLog(runtimeLogLevelError, err.Error())
}
for range jobs {
+44
View File
@@ -0,0 +1,44 @@
{ lib
, buildGoModule
, go_1_26
, rev ? "unknown"
, buildDate ? "unknown"
}:
(buildGoModule.override { go = go_1_26; }) (finalAttrs: {
pname = "sarin";
version = "1.4.2"; # bump per release
src = lib.cleanSource ../.;
vendorHash = "sha256-bxIJoAsrzXMeTyjIGKL0GgVddJFpieuMRsDihWw+EkM=";
subPackages = [ "cmd/cli" ];
env.CGO_ENABLED = 0; # fully static binary
ldflags = [
"-s"
"-w"
"-X=go.aykhans.me/sarin/internal/version.Version=v${finalAttrs.version}"
"-X=go.aykhans.me/sarin/internal/version.GitCommit=${rev}"
"-X=go.aykhans.me/sarin/internal/version.BuildDate=${buildDate}"
];
preBuild = ''
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
'';
meta = {
description = "High-performance HTTP load testing tool built with Go and fasthttp";
homepage = "https://github.com/aykhans/sarin";
license = lib.licenses.mit;
mainProgram = "sarin";
maintainers = [ ];
};
})