diff --git a/.dockerignore b/.dockerignore
deleted file mode 100644
index 4da4366..0000000
--- a/.dockerignore
+++ /dev/null
@@ -1,11 +0,0 @@
-.github
-assets
-binaries
-dodo
-.git
-.gitignore
-.golangci.yml
-README.md
-LICENSE
-config.json
-build.sh
\ No newline at end of file
diff --git a/.golangci.yml b/.golangci.yml
index 5bc019a..347957d 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -24,6 +24,72 @@ linters:
- unconvert
- unused
- whitespace
+ - bidichk
+ - bodyclose
+ - containedctx
+ - contextcheck
+ - copyloopvar
+ - decorder
+ - dogsled
+ - dupword
+ - durationcheck
+ - embeddedstructfieldcheck
+ - errchkjson
+ - errorlint
+ - exhaustive
+ - exptostd
+ - fatcontext
+ - forcetypeassert
+ - funcorder
+ - ginkgolinter
+ - gocheckcompilerdirectives
+ - gochecknoinits
+ - gochecksumtype
+ - goconst
+ - gocritic
+ - gocyclo
+ - godox
+ - goheader
+ - gomoddirectives
+ - gosec
+ - gosmopolitan
+ - grouper
+ - iface
+ - importas
+ - inamedparam
+ - interfacebloat
+ - intrange
+ - ireturn
+ - loggercheck
+ - makezero
+ - mirror
+ - musttag
+ - nilerr
+ - nilnesserr
+ - nilnil
+ - noctx
+ - nonamedreturns
+ - nosprintfhostport
+ - perfsprint
+ - predeclared
+ - promlinter
+ - protogetter
+ - sloglint
+ - spancheck
+ - sqlclosecheck
+ - tagalign
+ - tagliatelle
+ - testableexamples
+ - testifylint
+ - thelper
+ - tparallel
+ - unparam
+ - usestdlibvars
+ - usetesting
+ - varnamelen
+ - wastedassign
+ - wrapcheck
+ - zerologlint
settings:
staticcheck:
@@ -31,3 +97,39 @@ linters:
- "all"
- "-S1002"
- "-ST1000"
+ varnamelen:
+ ignore-decls:
+ - i int
+
+ exclusions:
+ rules:
+ - path: _test\.go$
+ linters:
+ - errorlint
+ - forcetypeassert
+ - perfsprint
+ - errcheck
+ - gosec
+
+ - path: _test\.go$
+ linters:
+ - staticcheck
+ text: "SA5011"
+
+formatters:
+ enable:
+ - gofmt
+
+ settings:
+ gofmt:
+ # Simplify code: gofmt with `-s` option.
+ # Default: true
+ simplify: false
+ # Apply the rewrite rules to the source before reformatting.
+ # https://pkg.go.dev/cmd/gofmt
+ # Default: []
+ rewrite-rules:
+ - pattern: "interface{}"
+ replacement: "any"
+ - pattern: "a[b:len(a)]"
+ replacement: "a[b:]"
diff --git a/EXAMPLES.md b/EXAMPLES.md
deleted file mode 100644
index d021603..0000000
--- a/EXAMPLES.md
+++ /dev/null
@@ -1,934 +0,0 @@
-# Dodo Usage Examples
-
-This document provides comprehensive examples of using Dodo with various configuration combinations. Each example includes three methods: CLI usage, YAML configuration, and JSON configuration.
-
-## Table of Contents
-
-1. [Basic HTTP Stress Testing](#1-basic-http-stress-testing)
-2. [POST Request with Form Data](#2-post-request-with-form-data)
-3. [API Testing with Authentication](#3-api-testing-with-authentication)
-4. [Testing with Custom Headers and Cookies](#4-testing-with-custom-headers-and-cookies)
-5. [Load Testing with Proxy Rotation](#5-load-testing-with-proxy-rotation)
-6. [JSON API Testing with Dynamic Data](#6-json-api-testing-with-dynamic-data)
-7. [File Upload Testing](#7-file-upload-testing)
-8. [E-commerce Cart Testing](#8-e-commerce-cart-testing)
-9. [GraphQL API Testing](#9-graphql-api-testing)
-10. [WebSocket-style HTTP Testing](#10-websocket-style-http-testing)
-11. [Multi-tenant Application Testing](#11-multi-tenant-application-testing)
-12. [Rate Limiting Testing](#12-rate-limiting-testing)
-
----
-
-## 1. Basic HTTP Stress Testing
-
-Test a simple website with basic GET requests to measure performance under load.
-
-### CLI Usage
-
-```bash
-dodo -u https://httpbin.org/get \
- -m GET \
- -d 5 \
- -r 100 \
- -t 5s \
- -o 30s \
- --skip-verify=false \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "GET"
-url: "https://httpbin.org/get"
-yes: true
-timeout: "5s"
-dodos: 5
-requests: 100
-duration: "30s"
-skip_verify: false
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "GET",
- "url": "https://httpbin.org/get",
- "yes": true,
- "timeout": "5s",
- "dodos": 5,
- "requests": 100,
- "duration": "30s",
- "skip_verify": false
-}
-```
-
----
-
-## 2. POST Request with Form Data
-
-Test form submission endpoints with randomized form data.
-
-### CLI Usage
-
-```bash
-dodo -u https://httpbin.org/post \
- -m POST \
- -d 3 \
- -r 50 \
- -t 10s \
- --skip-verify=false \
- -H "Content-Type:application/x-www-form-urlencoded" \
- -b "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 12 }}&email={{ fakeit_Email }}" \
- -b "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 8 }}&email={{ fakeit_Email }}" \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "POST"
-url: "https://httpbin.org/post"
-yes: true
-timeout: "10s"
-dodos: 3
-requests: 50
-skip_verify: false
-
-headers:
- - Content-Type: "application/x-www-form-urlencoded"
-
-body:
- - "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 12 }}&email={{ fakeit_Email }}"
- - "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 8 }}&email={{ fakeit_Email }}"
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "POST",
- "url": "https://httpbin.org/post",
- "yes": true,
- "timeout": "10s",
- "dodos": 3,
- "requests": 50,
- "skip_verify": false,
- "headers": [{ "Content-Type": "application/x-www-form-urlencoded" }],
- "body": [
- "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 12 }}&email={{ fakeit_Email }}",
- "username={{ fakeit_Username }}&password={{ fakeit_Password true true true true true 8 }}&email={{ fakeit_Email }}"
- ]
-}
-```
-
----
-
-## 3. API Testing with Authentication
-
-Test protected API endpoints with various authentication methods.
-
-### CLI Usage
-
-```bash
-dodo -u https://httpbin.org/bearer \
- -m GET \
- -d 4 \
- -r 200 \
- -t 8s \
- --skip-verify=false \
- -H "Authorization:Bearer {{ fakeit_LetterN 32 }}" \
- -H "User-Agent:{{ fakeit_UserAgent }}" \
- -H "X-Request-ID:{{ fakeit_Int }}" \
- -H "Accept:application/json" \
- -p "api_version=v1" \
- -p "format=json" \
- -p "client_id=mobile" -p "client_id=web" -p "client_id=desktop" \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "GET"
-url: "https://httpbin.org/bearer"
-yes: true
-timeout: "8s"
-dodos: 4
-requests: 200
-skip_verify: false
-
-params:
- - api_version: "v1"
- - format: "json"
- - client_id: ["mobile", "web", "desktop"]
-
-headers:
- - Authorization: "Bearer {{ fakeit_LetterN 32 }}"
- - User-Agent: "{{ fakeit_UserAgent }}"
- - X-Request-ID: "{{ fakeit_Int }}"
- - Accept: "application/json"
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "GET",
- "url": "https://httpbin.org/bearer",
- "yes": true,
- "timeout": "8s",
- "dodos": 4,
- "requests": 200,
- "skip_verify": false,
- "params": [
- { "api_version": "v1" },
- { "format": "json" },
- { "client_id": ["mobile", "web", "desktop"] }
- ],
- "headers": [
- { "Authorization": "Bearer {{ fakeit_LetterN 32 }}" },
- { "User-Agent": "{{ fakeit_UserAgent }}" },
- { "X-Request-ID": "{{ fakeit_Int }}" },
- { "Accept": "application/json" }
- ]
-}
-```
-
----
-
-## 4. Testing with Custom Headers and Cookies
-
-Test applications that require specific headers and session cookies.
-
-### CLI Usage
-
-```bash
-dodo -u https://httpbin.org/cookies \
- -m GET \
- -d 6 \
- -r 75 \
- -t 5s \
- --skip-verify=false \
- -H 'Accept-Language:{{ strings_Join "," (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) }}' \
- -H "X-Forwarded-For:{{ fakeit_IPv4Address }}" \
- -H "X-Real-IP:{{ fakeit_IPv4Address }}" \
- -H "Accept-Encoding:gzip" -H "Accept-Encoding:deflate" -H "Accept-Encoding:br" \
- -c "session_id={{ fakeit_UUID }}" \
- -c 'user_pref={{ fakeit_RandomString "a1" "b2" "c3" }}' \
- -c "theme=dark" -c "theme=light" -c "theme=auto" \
- -c "lang=en" -c "lang=es" -c "lang=fr" -c "lang=de" \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "GET"
-url: "https://httpbin.org/cookies"
-yes: true
-timeout: "5s"
-dodos: 6
-requests: 75
-skip_verify: false
-
-headers:
- - Accept-Language: '{{ strings_Join "," (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) }}'
- - X-Forwarded-For: "{{ fakeit_IPv4Address }}"
- - X-Real-IP: "{{ fakeit_IPv4Address }}"
- - Accept-Encoding: ["gzip", "deflate", "br"]
-
-cookies:
- - session_id: "{{ fakeit_UUID }}"
- - user_pref: '{{ fakeit_RandomString "a1" "b2" "c3" }}'
- - theme: ["dark", "light", "auto"]
- - lang: ["en", "es", "fr", "de"]
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "GET",
- "url": "https://httpbin.org/cookies",
- "yes": true,
- "timeout": "5s",
- "dodos": 6,
- "requests": 75,
- "skip_verify": false,
- "headers": [
- {
- "Accept-Language": "{{ strings_Join \",\" (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) (fakeit_LanguageAbbreviation) }}"
- },
- { "X-Forwarded-For": "{{ fakeit_IPv4Address }}" },
- { "X-Real-IP": "{{ fakeit_IPv4Address }}" },
- { "Accept-Encoding": ["gzip", "deflate", "br"] }
- ],
- "cookies": [
- { "session_id": "{{ fakeit_UUID }}" },
- { "user_pref": "{{ fakeit_RandomString \"a1\" \"b2\" \"c3\" }}" },
- { "theme": ["dark", "light", "auto"] },
- { "lang": ["en", "es", "fr", "de"] }
- ]
-}
-```
-
----
-
-## 5. Load Testing with Proxy Rotation
-
-Test through multiple proxies for distributed load testing.
-
-### CLI Usage
-
-```bash
-dodo -u https://httpbin.org/ip \
- -m GET \
- -d 8 \
- -r 300 \
- -t 15s \
- --skip-verify=false \
- -x "http://proxy1.example.com:8080" \
- -x "http://proxy2.example.com:8080" \
- -x "socks5://proxy3.example.com:1080" \
- -x "http://username:password@proxy4.example.com:8080" \
- -H "User-Agent:{{ fakeit_UserAgent }}" \
- -H "Accept:application/json" \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "GET"
-url: "https://httpbin.org/ip"
-yes: true
-timeout: "15s"
-dodos: 8
-requests: 300
-skip_verify: false
-
-proxy:
- - "http://proxy1.example.com:8080"
- - "http://proxy2.example.com:8080"
- - "socks5://proxy3.example.com:1080"
- - "http://username:password@proxy4.example.com:8080"
-
-headers:
- - User-Agent: "{{ fakeit_UserAgent }}"
- - Accept: "application/json"
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "GET",
- "url": "https://httpbin.org/ip",
- "yes": true,
- "timeout": "15s",
- "dodos": 8,
- "requests": 300,
- "skip_verify": false,
- "proxy": [
- "http://proxy1.example.com:8080",
- "http://proxy2.example.com:8080",
- "socks5://proxy3.example.com:1080",
- "http://username:password@proxy4.example.com:8080"
- ],
- "headers": [
- { "User-Agent": "{{ fakeit_UserAgent }}" },
- { "Accept": "application/json" }
- ]
-}
-```
-
----
-
-## 6. JSON API Testing with Dynamic Data
-
-Test REST APIs with realistic JSON payloads.
-
-### CLI Usage
-
-```bash
-dodo -u https://httpbin.org/post \
- -m POST \
- -d 5 \
- -r 150 \
- -t 12s \
- --skip-verify=false \
- -H "Content-Type:application/json" \
- -H "Accept:application/json" \
- -H "X-API-Version:2023-10-01" \
- -b '{"user_id":{{ fakeit_Uint }},"name":"{{ fakeit_Name }}","email":"{{ fakeit_Email }}","created_at":"{{ fakeit_Date }}"}' \
- -b '{"product_id":{{ fakeit_Uint }},"name":"{{ fakeit_ProductName }}","price":{{ fakeit_Price 10 1000 }},"category":"{{ fakeit_ProductCategory }}"}' \
- -b '{"order_id":"{{ fakeit_UUID }}","items":[{"id":{{ fakeit_Uint }},"quantity":{{ fakeit_IntRange 1 10 }}}],"total":{{ fakeit_Price 50 500 }}}' \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "POST"
-url: "https://httpbin.org/post"
-yes: true
-timeout: "12s"
-dodos: 5
-requests: 150
-skip_verify: false
-
-headers:
- - Content-Type: "application/json"
- - Accept: "application/json"
- - X-API-Version: "2023-10-01"
-
-body:
- - '{"user_id":{{ fakeit_Uint }},"name":"{{ fakeit_Name }}","email":"{{ fakeit_Email }}","created_at":"{{ fakeit_Date }}"}'
- - '{"product_id":{{ fakeit_Uint }},"name":"{{ fakeit_ProductName }}","price":{{ fakeit_Price 10 1000 }},"category":"{{ fakeit_ProductCategory }}"}'
- - '{"order_id":"{{ fakeit_UUID }}","items":[{"id":{{ fakeit_Uint }},"quantity":{{ fakeit_IntRange 1 10 }}}],"total":{{ fakeit_Price 50 500 }}}'
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "POST",
- "url": "https://httpbin.org/post",
- "yes": true,
- "timeout": "12s",
- "dodos": 5,
- "requests": 150,
- "skip_verify": false,
- "headers": [
- { "Content-Type": "application/json" },
- { "Accept": "application/json" },
- { "X-API-Version": "2023-10-01" }
- ],
- "body": [
- "{\"user_id\":{{ fakeit_Uint }},\"name\":\"{{ fakeit_Name }}\",\"email\":\"{{ fakeit_Email }}\",\"created_at\":\"{{ fakeit_Date }}\"}",
- "{\"product_id\":{{ fakeit_Uint }},\"name\":\"{{ fakeit_ProductName }}\",\"price\":{{ fakeit_Price 10 1000 }},\"category\":\"{{ fakeit_ProductCategory }}\"}",
- "{\"order_id\":\"{{ fakeit_UUID }}\",\"items\":[{\"id\":{{ fakeit_Uint }},\"quantity\":{{ fakeit_IntRange 1 10 }}}],\"total\":{{ fakeit_Price 50 500 }}}"
- ]
-}
-```
-
----
-
-## 7. File Upload Testing
-
-Test file upload endpoints with multipart form data.
-
-### CLI Usage
-
-```bash
-dodo -u https://httpbin.org/post \
- -m POST \
- -d 3 \
- -r 25 \
- -t 30s \
- --skip-verify=false \
- -H "X-Upload-Source:dodo-test" \
- -H "User-Agent:{{ fakeit_UserAgent }}" \
- -b '{{ body_FormData (dict_Str "filename" (fakeit_UUID) "content" (fakeit_Paragraph 3 5 10 " ")) }}' \
- -b '{{ body_FormData (dict_Str "file" (fakeit_UUID) "description" (fakeit_Sentence 10) "category" "image") }}' \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "POST"
-url: "https://httpbin.org/post"
-yes: true
-timeout: "30s"
-dodos: 3
-requests: 25
-skip_verify: false
-
-headers:
- - X-Upload-Source: "dodo-test"
- - User-Agent: "{{ fakeit_UserAgent }}"
-
-body:
- - '{{ body_FormData (dict_Str "filename" (fakeit_UUID) "content" (fakeit_Paragraph 3 5 10 " ")) }}'
- - '{{ body_FormData (dict_Str "file" (fakeit_UUID) "description" (fakeit_Sentence 10) "category" "image") }}'
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "POST",
- "url": "https://httpbin.org/post",
- "yes": true,
- "timeout": "30s",
- "dodos": 3,
- "requests": 25,
- "skip_verify": false,
- "headers": [
- { "X-Upload-Source": "dodo-test" },
- { "User-Agent": "{{ fakeit_UserAgent }}" }
- ],
- "body": [
- "{{ body_FormData (dict_Str \"filename\" (fakeit_UUID) \"content\" (fakeit_Paragraph 3 5 10 \" \")) }}",
- "{{ body_FormData (dict_Str \"file\" (fakeit_UUID) \"description\" (fakeit_Sentence 10) \"category\" \"image\") }}"
- ]
-}
-```
-
----
-
-## 8. E-commerce Cart Testing
-
-Test shopping cart operations with realistic product data.
-
-### CLI Usage
-
-```bash
-dodo -u https://api.example-shop.com/cart \
- -m POST \
- -d 10 \
- -r 500 \
- -t 8s \
- --skip-verify=false \
- -H "Content-Type:application/json" \
- -H "Authorization:Bearer {{ fakeit_LetterN 32 }}" \
- -H "X-Client-Version:1.2.3" \
- -H "User-Agent:{{ fakeit_UserAgent }}" \
- -c "cart_session={{ fakeit_UUID }}" \
- -c "user_pref=guest" -c "user_pref=member" -c "user_pref=premium" \
- -c "region=US" -c "region=EU" -c "region=ASIA" \
- -p "currency=USD" -p "currency=EUR" -p "currency=GBP" \
- -p "locale=en-US" -p "locale=en-GB" -p "locale=de-DE" -p "locale=fr-FR" \
- -b '{"action":"add","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 5 }},"user_id":"{{ fakeit_UUID }}"}' \
- -b '{"action":"remove","product_id":"{{ fakeit_UUID }}","user_id":"{{ fakeit_UUID }}"}' \
- -b '{"action":"update","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 10 }},"user_id":"{{ fakeit_UUID }}"}' \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "POST"
-url: "https://api.example-shop.com/cart"
-yes: true
-timeout: "8s"
-dodos: 10
-requests: 500
-skip_verify: false
-
-headers:
- - Content-Type: "application/json"
- - Authorization: "Bearer {{ fakeit_LetterN 32 }}"
- - X-Client-Version: "1.2.3"
- - User-Agent: "{{ fakeit_UserAgent }}"
-
-cookies:
- - cart_session: "{{ fakeit_UUID }}"
- - user_pref: ["guest", "member", "premium"]
- - region: ["US", "EU", "ASIA"]
-
-params:
- - currency: ["USD", "EUR", "GBP"]
- - locale: ["en-US", "en-GB", "de-DE", "fr-FR"]
-
-body:
- - '{"action":"add","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 5 }},"user_id":"{{ fakeit_UUID }}"}'
- - '{"action":"remove","product_id":"{{ fakeit_UUID }}","user_id":"{{ fakeit_UUID }}"}'
- - '{"action":"update","product_id":"{{ fakeit_UUID }}","quantity":{{ fakeit_IntRange 1 10 }},"user_id":"{{ fakeit_UUID }}"}'
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "POST",
- "url": "https://api.example-shop.com/cart",
- "yes": true,
- "timeout": "8s",
- "dodos": 10,
- "requests": 500,
- "skip_verify": false,
- "headers": [
- { "Content-Type": "application/json" },
- { "Authorization": "Bearer {{ fakeit_LetterN 32 }}" },
- { "X-Client-Version": "1.2.3" },
- { "User-Agent": "{{ fakeit_UserAgent }}" }
- ],
- "cookies": [
- { "cart_session": "{{ fakeit_UUID }}" },
- { "user_pref": ["guest", "member", "premium"] },
- { "region": ["US", "EU", "ASIA"] }
- ],
- "params": [
- { "currency": ["USD", "EUR", "GBP"] },
- { "locale": ["en-US", "en-GB", "de-DE", "fr-FR"] }
- ],
- "body": [
- "{\"action\":\"add\",\"product_id\":\"{{ fakeit_UUID }}\",\"quantity\":{{ fakeit_IntRange 1 5 }},\"user_id\":\"{{ fakeit_UUID }}\"}",
- "{\"action\":\"remove\",\"product_id\":\"{{ fakeit_UUID }}\",\"user_id\":\"{{ fakeit_UUID }}\"}",
- "{\"action\":\"update\",\"product_id\":\"{{ fakeit_UUID }}\",\"quantity\":{{ fakeit_IntRange 1 10 }},\"user_id\":\"{{ fakeit_UUID }}\"}"
- ]
-}
-```
-
----
-
-## 9. GraphQL API Testing
-
-Test GraphQL endpoints with various queries and mutations.
-
-### CLI Usage
-
-```bash
-dodo -u https://api.example.com/graphql \
- -m POST \
- -d 4 \
- -r 100 \
- -t 10s \
- --skip-verify=false \
- -H "Content-Type:application/json" \
- -H "Authorization:Bearer {{ fakeit_UUID }}" \
- -H "X-GraphQL-Client:dodo-test" \
- -b '{"query":"query GetUser($id: ID!) { user(id: $id) { id name email } }","variables":{"id":"{{ fakeit_UUID }}"}}' \
- -b '{"query":"query GetPosts($limit: Int) { posts(limit: $limit) { id title content } }","variables":{"limit":{{ fakeit_IntRange 5 20 }}}}' \
- -b '{"query":"mutation CreatePost($input: PostInput!) { createPost(input: $input) { id title } }","variables":{"input":{"title":"{{ fakeit_Sentence 5 }}","content":"{{ fakeit_Paragraph 2 3 5 " "}}","authorId":"{{ fakeit_UUID }}"}}}' \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "POST"
-url: "https://api.example.com/graphql"
-yes: true
-timeout: "10s"
-dodos: 4
-requests: 100
-skip_verify: false
-
-headers:
- - Content-Type: "application/json"
- - Authorization: "Bearer {{ fakeit_UUID }}"
- - X-GraphQL-Client: "dodo-test"
-
-body:
- - '{"query":"query GetUser($id: ID!) { user(id: $id) { id name email } }","variables":{"id":"{{ fakeit_UUID }}"}}'
- - '{"query":"query GetPosts($limit: Int) { posts(limit: $limit) { id title content } }","variables":{"limit":{{ fakeit_IntRange 5 20 }}}}'
- - '{"query":"mutation CreatePost($input: PostInput!) { createPost(input: $input) { id title } }","variables":{"input":{"title":"{{ fakeit_Sentence 5 }}","content":"{{ fakeit_Paragraph 2 3 5 " "}}","authorId":"{{ fakeit_UUID }}"}}}'
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "POST",
- "url": "https://api.example.com/graphql",
- "yes": true,
- "timeout": "10s",
- "dodos": 4,
- "requests": 100,
- "skip_verify": false,
- "headers": [
- { "Content-Type": "application/json" },
- { "Authorization": "Bearer {{ fakeit_UUID }}" },
- { "X-GraphQL-Client": "dodo-test" }
- ],
- "body": [
- "{\"query\":\"query GetUser($id: ID!) { user(id: $id) { id name email } }\",\"variables\":{\"id\":\"{{ fakeit_UUID }}\"}}",
- "{\"query\":\"query GetPosts($limit: Int) { posts(limit: $limit) { id title content } }\",\"variables\":{\"limit\":{{ fakeit_IntRange 5 20 }}}}",
- "{\"query\":\"mutation CreatePost($input: PostInput!) { createPost(input: $input) { id title } }\",\"variables\":{\"input\":{\"title\":\"{{ fakeit_Sentence 5 }}\",\"content\":\"{{ fakeit_Paragraph 2 3 5 \\\" \\\"}}\",\"authorId\":\"{{ fakeit_UUID }}\"}}}"
- ]
-}
-```
-
----
-
-## 10. WebSocket-style HTTP Testing
-
-Test real-time applications with WebSocket-like HTTP endpoints.
-
-### CLI Usage
-
-```bash
-dodo -u https://api.realtime-app.com/events \
- -m POST \
- -d 15 \
- -r 1000 \
- -t 5s \
- -o 60s \
- --skip-verify=false \
- -H "Content-Type:application/json" \
- -H "X-Event-Type:{{ fakeit_LetterNN 4 12 }}" \
- -H "Connection:keep-alive" \
- -H "Cache-Control:no-cache" \
- -c "connection_id={{ fakeit_UUID }}" \
- -c "session_token={{ fakeit_UUID }}" \
- -p "channel=general" -p "channel=notifications" -p "channel=alerts" -p "channel=updates" \
- -p "version=v1" -p "version=v2" \
- -b '{"event":"{{ fakeit_Word }}","data":{"timestamp":"{{ fakeit_Date }}","user_id":"{{ fakeit_UUID }}","message":"{{ fakeit_Sentence 8 }}"}}' \
- -b '{"event":"ping","data":{"timestamp":"{{ fakeit_Date }}","client_id":"{{ fakeit_UUID }}"}}' \
- -b '{"event":"status_update","data":{"status":"{{ fakeit_Word }}","user_id":"{{ fakeit_UUID }}","timestamp":"{{ fakeit_Date }}"}}' \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "POST"
-url: "https://api.realtime-app.com/events"
-yes: true
-timeout: "5s"
-dodos: 15
-requests: 1000
-duration: "60s"
-skip_verify: false
-
-headers:
- - Content-Type: "application/json"
- - X-Event-Type: "{{ fakeit_LetterNN 4 12 }}"
- - Connection: "keep-alive"
- - Cache-Control: "no-cache"
-
-cookies:
- - connection_id: "{{ fakeit_UUID }}"
- - session_token: "{{ fakeit_UUID }}"
-
-params:
- - channel: ["general", "notifications", "alerts", "updates"]
- - version: ["v1", "v2"]
-
-body:
- - '{"event":"{{ fakeit_Word }}","data":{"timestamp":"{{ fakeit_Date }}","user_id":"{{ fakeit_UUID }}","message":"{{ fakeit_Sentence 8 }}"}}'
- - '{"event":"ping","data":{"timestamp":"{{ fakeit_Date }}","client_id":"{{ fakeit_UUID }}"}}'
- - '{"event":"status_update","data":{"status":"{{ fakeit_Word }}","user_id":"{{ fakeit_UUID }}","timestamp":"{{ fakeit_Date }}"}}'
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "POST",
- "url": "https://api.realtime-app.com/events",
- "yes": true,
- "timeout": "5s",
- "dodos": 15,
- "requests": 1000,
- "duration": "60s",
- "skip_verify": false,
- "headers": [
- { "Content-Type": "application/json" },
- { "X-Event-Type": "{{ fakeit_LetterNN 4 12 }}" },
- { "Connection": "keep-alive" },
- { "Cache-Control": "no-cache" }
- ],
- "cookies": [
- { "connection_id": "{{ fakeit_UUID }}" },
- { "session_token": "{{ fakeit_UUID }}" }
- ],
- "params": [
- { "channel": ["general", "notifications", "alerts", "updates"] },
- { "version": ["v1", "v2"] }
- ],
- "body": [
- "{\"event\":\"{{ fakeit_Word }}\",\"data\":{\"timestamp\":\"{{ fakeit_Date }}\",\"user_id\":\"{{ fakeit_UUID }}\",\"message\":\"{{ fakeit_Sentence 8 }}\"}}",
- "{\"event\":\"ping\",\"data\":{\"timestamp\":\"{{ fakeit_Date }}\",\"client_id\":\"{{ fakeit_UUID }}\"}}",
- "{\"event\":\"status_update\",\"data\":{\"status\":\"{{ fakeit_Word }}\",\"user_id\":\"{{ fakeit_UUID }}\",\"timestamp\":\"{{ fakeit_Date }}\"}}"
- ]
-}
-```
-
----
-
-## 11. Multi-tenant Application Testing
-
-Test SaaS applications with tenant-specific configurations.
-
-### CLI Usage
-
-```bash
-dodo -u https://app.saas-platform.com/api/data \
- -m GET \
- -d 12 \
- -r 600 \
- -t 15s \
- --skip-verify=false \
- -H "X-Tenant-ID:{{ fakeit_UUID }}" \
- -H "Authorization:Bearer {{ fakeit_LetterN 64 }}" \
- -H "X-Client-Type:web" -H "X-Client-Type:mobile" -H "X-Client-Type:api" \
- -H "Accept:application/json" \
- -c "tenant_session={{ fakeit_UUID }}" \
- -c "user_role=admin" -c "user_role=user" -c "user_role=viewer" \
- -c "subscription_tier=free" -c "subscription_tier=pro" -c "subscription_tier=enterprise" \
- -p "page={{ fakeit_IntRange 1 10 }}" \
- -p "limit={{ fakeit_IntRange 10 100 }}" \
- -p "sort=created_at" -p "sort=updated_at" -p "sort=name" \
- -p "order=asc" -p "order=desc" \
- -p "filter_by=active" -p "filter_by=inactive" -p "filter_by=pending" \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "GET"
-url: "https://app.saas-platform.com/api/data"
-yes: true
-timeout: "15s"
-dodos: 12
-requests: 600
-skip_verify: false
-
-headers:
- - X-Tenant-ID: "{{ fakeit_UUID }}"
- - Authorization: "Bearer {{ fakeit_LetterN 64 }}"
- - X-Client-Type: ["web", "mobile", "api"]
- - Accept: "application/json"
-
-cookies:
- - tenant_session: "{{ fakeit_UUID }}"
- - user_role: ["admin", "user", "viewer"]
- - subscription_tier: ["free", "pro", "enterprise"]
-
-params:
- - page: "{{ fakeit_IntRange 1 10 }}"
- - limit: "{{ fakeit_IntRange 10 100 }}"
- - sort: ["created_at", "updated_at", "name"]
- - order: ["asc", "desc"]
- - filter_by: ["active", "inactive", "pending"]
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "GET",
- "url": "https://app.saas-platform.com/api/data",
- "yes": true,
- "timeout": "15s",
- "dodos": 12,
- "requests": 600,
- "skip_verify": false,
- "headers": [
- { "X-Tenant-ID": "{{ fakeit_UUID }}" },
- { "Authorization": "Bearer {{ fakeit_LetterN 64 }}" },
- { "X-Client-Type": ["web", "mobile", "api"] },
- { "Accept": "application/json" }
- ],
- "cookies": [
- { "tenant_session": "{{ fakeit_UUID }}" },
- { "user_role": ["admin", "user", "viewer"] },
- { "subscription_tier": ["free", "pro", "enterprise"] }
- ],
- "params": [
- { "page": "{{ fakeit_IntRange 1 10 }}" },
- { "limit": "{{ fakeit_IntRange 10 100 }}" },
- { "sort": ["created_at", "updated_at", "name"] },
- { "order": ["asc", "desc"] },
- { "filter_by": ["active", "inactive", "pending"] }
- ]
-}
-```
-
----
-
-## 12. Rate Limiting Testing
-
-Test API rate limits and throttling mechanisms.
-
-### CLI Usage
-
-```bash
-dodo -u https://api.rate-limited.com/endpoint \
- -m GET \
- -d 20 \
- -r 2000 \
- -t 3s \
- -o 120s \
- --skip-verify=false \
- -H "X-API-Key:{{ fakeit_UUID }}" \
- -H "X-Client-ID:{{ fakeit_UUID }}" \
- -H "X-Rate-Limit-Test:true" \
- -H "User-Agent:{{ fakeit_UserAgent }}" \
- -c "rate_limit_bucket={{ fakeit_UUID }}" \
- -c "client_tier=tier1" -c "client_tier=tier2" -c "client_tier=tier3" \
- -p "burst_test=true" \
- -p "client_type=premium" -p "client_type=standard" -p "client_type=free" \
- -p "request_id={{ fakeit_UUID }}" \
- -y
-```
-
-### YAML Configuration
-
-```yaml
-method: "GET"
-url: "https://api.rate-limited.com/endpoint"
-yes: true
-timeout: "3s"
-dodos: 20
-requests: 2000
-duration: "120s"
-skip_verify: false
-
-headers:
- - X-API-Key: "{{ fakeit_UUID }}"
- - X-Client-ID: "{{ fakeit_UUID }}"
- - X-Rate-Limit-Test: "true"
- - User-Agent: "{{ fakeit_UserAgent }}"
-
-params:
- - burst_test: "true"
- - client_type: ["premium", "standard", "free"]
- - request_id: "{{ fakeit_UUID }}"
-
-cookies:
- - rate_limit_bucket: "{{ fakeit_UUID }}"
- - client_tier: ["tier1", "tier2", "tier3"]
-```
-
-### JSON Configuration
-
-```json
-{
- "method": "GET",
- "url": "https://api.rate-limited.com/endpoint",
- "yes": true,
- "timeout": "3s",
- "dodos": 20,
- "requests": 2000,
- "duration": "120s",
- "skip_verify": false,
- "headers": [
- { "X-API-Key": "{{ fakeit_UUID }}" },
- { "X-Client-ID": "{{ fakeit_UUID }}" },
- { "X-Rate-Limit-Test": "true" },
- { "User-Agent": "{{ fakeit_UserAgent }}" }
- ],
- "params": [
- { "burst_test": "true" },
- { "client_type": ["premium", "standard", "free"] },
- { "request_id": "{{ fakeit_UUID }}" }
- ],
- "cookies": [
- { "rate_limit_bucket": "{{ fakeit_UUID }}" },
- { "client_tier": ["tier1", "tier2", "tier3"] }
- ]
-}
-```
-
----
-
-## Notes
-
-- All examples use template functions for dynamic data generation
-- Adjust `dodos`, `requests`, `duration`, and `timeout` values based on your testing requirements
-- Use `skip_verify: true` for testing with self-signed certificates
-- Set `yes: true` to skip confirmation prompts in automated testing
-- Template functions like `{{ fakeit_* }}` generate random realistic data for each request
-- Multiple values in arrays (e.g., `["value1", "value2"]`) will be randomly selected per request
-- Use the `body_FormData` function for multipart form uploads
-- Proxy configurations support HTTP, SOCKS5, and SOCKS5H protocols
-
-For more template functions and advanced configuration options, refer to the main documentation and `utils/templates.go`.
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 8131b39..0000000
--- a/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2024 Aykhan Shahsuvarov
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/README.md b/README.md
deleted file mode 100644
index b431888..0000000
--- a/README.md
+++ /dev/null
@@ -1,340 +0,0 @@
-
Dodo - A Fast and Easy-to-Use HTTP Benchmarking Tool
-
-
-
-
-
-## Table of Contents
-
-- [Installation](#installation)
- - [Using Docker (Recommended)](#using-docker-recommended)
- - [Using Pre-built Binaries](#using-pre-built-binaries)
- - [Building from Source](#building-from-source)
-- [Usage](#usage)
- - [1. CLI Usage](#1-cli-usage)
- - [2. Config File Usage](#2-config-file-usage)
- - [2.1 YAML/YML Example](#21-yamlyml-example)
- - [2.2 JSON Example](#22-json-example)
- - [3. CLI & Config File Combination](#3-cli--config-file-combination)
-- [Config Parameters Reference](#config-parameters-reference)
-- [Template Functions](#template-functions)
-
-## Installation
-
-### Using Docker (Recommended)
-
-Pull the latest Dodo image from Docker Hub:
-
-```sh
-docker pull aykhans/dodo:latest
-```
-
-To use Dodo with Docker and a local config file, mount the config file as a volume and pass it as an argument:
-
-```sh
-docker run -v /path/to/config.json:/config.json aykhans/dodo -f /config.json
-```
-
-If you're using a remote config file via URL, you don't need to mount a volume:
-
-```sh
-docker run aykhans/dodo -f https://raw.githubusercontent.com/aykhans/dodo/main/config.yaml
-```
-
-### Using Pre-built Binaries
-
-Download the latest binaries from the [releases](https://github.com/aykhans/dodo/releases) section.
-
-### Building from Source
-
-To build Dodo from source, ensure you have [Go 1.24+](https://golang.org/dl/) installed.
-
-```sh
-go install -ldflags "-s -w" github.com/aykhans/dodo@latest
-```
-
-## Usage
-
-Dodo supports CLI arguments, configuration files (JSON/YAML), or a combination of both. If both are used, CLI arguments take precedence.
-
-### 1. CLI Usage
-
-Send 1000 GET requests to https://example.com with 10 parallel dodos (threads), each with a timeout of 2 seconds, within a maximum duration of 1 minute:
-
-```sh
-dodo -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 2s
-```
-
-With Docker:
-
-```sh
-docker run --rm -i aykhans/dodo -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 2s
-```
-
-### 2. Config File Usage
-
-Send 1000 GET requests to https://example.com with 10 parallel dodos (threads), each with a timeout of 800 milliseconds, within a maximum duration of 250 seconds:
-
-#### 2.1 YAML/YML Example
-
-```yaml
-method: "GET"
-url: "https://example.com"
-yes: false
-timeout: "800ms"
-dodos: 10
-requests: 1000
-duration: "250s"
-skip_verify: false
-
-params:
- # A random value will be selected from the list for first "key1" param on each request
- # And always "value" for second "key1" param on each request
- # e.g. "?key1=value2&key1=value"
- - key1: ["value1", "value2", "value3", "value4"]
- - key1: "value"
-
- # A random value will be selected from the list for param "key2" on each request
- # e.g. "?key2=value2"
- - key2: ["value1", "value2"]
-
-headers:
- # A random value will be selected from the list for first "key1" header on each request
- # And always "value" for second "key1" header on each request
- # e.g. "key1: value3", "key1: value"
- - key1: ["value1", "value2", "value3", "value4"]
- - key1: "value"
-
- # A random value will be selected from the list for header "key2" on each request
- # e.g. "key2: value2"
- - key2: ["value1", "value2"]
-
-cookies:
- # A random value will be selected from the list for first "key1" cookie on each request
- # And always "value" for second "key1" cookie on each request
- # e.g. "key1=value4; key1=value"
- - key1: ["value1", "value2", "value3", "value4"]
- - key1: "value"
-
- # A random value will be selected from the list for cookie "key2" on each request
- # e.g. "key2=value1"
- - key2: ["value1", "value2"]
-
-body: "body-text"
-# OR
-# A random body value will be selected from the list for each request
-body:
- - "body-text1"
- - "body-text2"
- - "body-text3"
-
-proxy: "http://example.com:8080"
-# OR
-# A random proxy will be selected from the list for each request
-proxy:
- - "http://example.com:8080"
- - "http://username:password@example.com:8080"
- - "socks5://example.com:8080"
- - "socks5h://example.com:8080"
-```
-
-```sh
-dodo -f /path/config.yaml
-# OR
-dodo -f https://example.com/config.yaml
-```
-
-With Docker:
-
-```sh
-docker run --rm -i -v /path/to/config.yaml:/config.yaml aykhans/dodo -f /config.yaml
-# OR
-docker run --rm -i aykhans/dodo -f https://example.com/config.yaml
-```
-
-#### 2.2 JSON Example
-
-```jsonc
-{
- "method": "GET",
- "url": "https://example.com",
- "yes": false,
- "timeout": "800ms",
- "dodos": 10,
- "requests": 1000,
- "duration": "250s",
- "skip_verify": false,
-
- "params": [
- // A random value will be selected from the list for first "key1" param on each request
- // And always "value" for second "key1" param on each request
- // e.g. "?key1=value2&key1=value"
- { "key1": ["value1", "value2", "value3", "value4"] },
- { "key1": "value" },
-
- // A random value will be selected from the list for param "key2" on each request
- // e.g. "?key2=value2"
- { "key2": ["value1", "value2"] },
- ],
-
- "headers": [
- // A random value will be selected from the list for first "key1" header on each request
- // And always "value" for second "key1" header on each request
- // e.g. "key1: value3", "key1: value"
- { "key1": ["value1", "value2", "value3", "value4"] },
- { "key1": "value" },
-
- // A random value will be selected from the list for header "key2" on each request
- // e.g. "key2: value2"
- { "key2": ["value1", "value2"] },
- ],
-
- "cookies": [
- // A random value will be selected from the list for first "key1" cookie on each request
- // And always "value" for second "key1" cookie on each request
- // e.g. "key1=value4; key1=value"
- { "key1": ["value1", "value2", "value3", "value4"] },
- { "key1": "value" },
-
- // A random value will be selected from the list for cookie "key2" on each request
- // e.g. "key2=value1"
- { "key2": ["value1", "value2"] },
- ],
-
- "body": "body-text",
- // OR
- // A random body value will be selected from the list for each request
- "body": ["body-text1", "body-text2", "body-text3"],
-
- "proxy": "http://example.com:8080",
- // OR
- // A random proxy will be selected from the list for each request
- "proxy": [
- "http://example.com:8080",
- "http://username:password@example.com:8080",
- "socks5://example.com:8080",
- "socks5h://example.com:8080",
- ],
-}
-```
-
-```sh
-dodo -f /path/config.json
-# OR
-dodo -f https://example.com/config.json
-```
-
-With Docker:
-
-```sh
-docker run --rm -i -v /path/to/config.json:/config.json aykhans/dodo
-# OR
-docker run --rm -i aykhans/dodo -f https://example.com/config.json
-```
-
-### 3. CLI & Config File Combination
-
-CLI arguments override config file values:
-
-```sh
-dodo -f /path/to/config.yaml -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 5s
-```
-
-With Docker:
-
-```sh
-docker run --rm -i -v /path/to/config.json:/config.json aykhans/dodo -f /config.json -u https://example.com -m GET -d 10 -r 1000 -o 1m -t 5s
-```
-
-You can find more usage examples in the [EXAMPLES.md](./EXAMPLES.md) file.
-
-## Config Parameters Reference
-
-If `Headers`, `Params`, `Cookies`, `Body`, or `Proxy` fields have multiple values, each request will choose a random value from the list.
-
-| Parameter | config file | CLI Flag | CLI Short Flag | Type | Description | Default |
-| --------------- | ----------- | ------------ | -------------- | ------------------------------ | ----------------------------------------------------------- | ------- |
-| Config file | | -config-file | -f | String | Path to local config file or http(s) URL of the config file | - |
-| Yes | yes | -yes | -y | Boolean | Answer yes to all questions | false |
-| URL | url | -url | -u | String | URL to send the request to | - |
-| Method | method | -method | -m | String | HTTP method | GET |
-| Dodos (Threads) | dodos | -dodos | -d | UnsignedInteger | Number of dodos (threads) to send requests in parallel | 1 |
-| Requests | requests | -requests | -r | UnsignedInteger | Total number of requests to send | - |
-| Duration | duration | -duration | -o | Time | Maximum duration for the test | - |
-| Timeout | timeout | -timeout | -t | Time | Timeout for canceling each request | 10s |
-| Params | params | -param | -p | [{String: String OR [String]}] | Request parameters | - |
-| Headers | headers | -header | -H | [{String: String OR [String]}] | Request headers | - |
-| Cookies | cookies | -cookie | -c | [{String: String OR [String]}] | Request cookies | - |
-| Body | body | -body | -b | String OR [String] | Request body or list of request bodies | - |
-| Proxy | proxies | -proxy | -x | String OR [String] | Proxy URL or list of proxy URLs | - |
-| Skip Verify | skip_verify | -skip-verify | | Boolean | Skip SSL/TLS certificate verification | false |
-
-## Template Functions
-
-Dodo supports template functions in `Headers`, `Params`, `Cookies`, and `Body` fields. These functions allow you to generate dynamic values for each request.
-
-You can use Go template syntax to include dynamic values in your requests. Here's how to use template functions:
-
-In CLI config:
-
-```sh
-dodo -u https://example.com -r 1 \
- -header "User-Agent:{{ fakeit_UserAgent }}" \ # e.g. "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
- -param "username={{ strings_ToUpper fakeit_Username }}" \ # e.g. "username=JOHN BOB"
- -cookie "token={{ fakeit_Password true true true true true 10 }}" \ # e.g. token=1234567890abcdef1234567890abcdef
- -body '{"email":"{{ fakeit_Email }}", "password":"{{ fakeit_Password true true true true true 10 }}"}' # e.g. {"email":"john.doe@example.com", "password":"12rw4d-78d"}
-```
-
-In YAML/YML config:
-
-```yaml
-headers:
- - User-Agent: "{{ fakeit_UserAgent }}" # e.g. "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
- - "Random-Header-{{fakeit_FirstName}}": "static_value" # e.g. "Random-Header-John: static_value"
-
-cookies:
- - token: "Bearer {{ fakeit_UUID }}" # e.g. "token=Bearer 1234567890abcdef1234567890abcdef"
-
-params:
- - id: "{{ fakeit_Uint }}" # e.g. "id=1234567890"
- - username: "{{ fakeit_Username }}" # e.g. "username=John Doe"
-
-body:
- - '{ "username": "{{ fakeit_Username }}", "password": "{{ fakeit_Password }}" }' # e.g. { "username": "john.doe", "password": "password123" }
- - '{ "email": "{{ fakeit_Email }}", "phone": "{{ fakeit_Phone }}" }' # e.g. { "email": "john.doe@example.com", "phone": "1234567890" }
- - '{{ body_FormData (dict_Str "username" fakeit_Username "password" "secret123") }}' # Creates multipart form data for form submissions, automatically sets the appropriate Content-Type header.
-```
-
-In JSON config:
-
-```jsonc
-{
- "headers": [
- { "User-Agent": "{{ fakeit_UserAgent }}" }, // e.g. "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
- ],
- "body": [
- "{ \"username\": \"{{ strings_RemoveSpaces fakeit_Username }}\", \"password\": \"{{ fakeit_Password }}\" }", // e.g. { "username": "johndoe", "password": "password123" }
- "{{ body_FormData (dict_Str \"username\" fakeit_Username \"password\" \"12345\") }}", // Creates multipart form data for form submissions, automatically sets the appropriate Content-Type header.
- ],
-}
-```
-
-For the full list of template functions over 200 functions, refer to the `NewFuncMap` function in `utils/templates.go`.
diff --git a/Taskfile.yaml b/Taskfile.yaml
index c1730c4..eb40384 100644
--- a/Taskfile.yaml
+++ b/Taskfile.yaml
@@ -2,23 +2,8 @@
version: "3"
-vars:
- PLATFORMS:
- - os: darwin
- archs: [amd64, arm64]
- - os: freebsd
- archs: [386, amd64, arm]
- - os: linux
- archs: [386, amd64, arm, arm64]
- - os: netbsd
- archs: [386, amd64, arm]
- - os: openbsd
- archs: [386, amd64, arm, arm64]
- - os: windows
- archs: [386, amd64, arm64]
-
tasks:
- run: go run main.go
+ run: go run cmd/cli/main.go {{.CLI_ARGS}}
ftl:
cmds:
@@ -26,28 +11,10 @@ tasks:
- task: tidy
- task: lint
- fmt: gofmt -w -d .
+ fmt: golangci-lint fmt
tidy: go mod tidy
lint: golangci-lint run
- build: CGO_ENABLED=0 go build -ldflags "-s -w" -o "dodo"
-
- build-all:
- silent: true
- cmds:
- - rm -rf binaries
- - |
- {{ $ext := "" }}
- {{- range $platform := .PLATFORMS }}
- {{- if eq $platform.os "windows" }}
- {{ $ext = ".exe" }}
- {{- end }}
-
- {{- range $arch := $platform.archs }}
- echo "Building for {{$platform.os}}/{{$arch}}"
- GOOS={{$platform.os}} GOARCH={{$arch}} go build -ldflags "-s -w" -o "./binaries/dodo-{{$platform.os}}-{{$arch}}{{$ext}}"
- {{- end }}
- {{- end }}
- - echo -e "\033[32m*** Build completed ***\033[0m"
+ test: go test ./...
diff --git a/cmd/cli/main.go b/cmd/cli/main.go
new file mode 100644
index 0000000..963d5a3
--- /dev/null
+++ b/cmd/cli/main.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/aykhans/dodo/pkg/config"
+ "github.com/aykhans/dodo/pkg/types"
+ "github.com/aykhans/dodo/pkg/utils"
+ "github.com/jedib0t/go-pretty/v6/text"
+)
+
+func main() {
+ cliParser := config.NewConfigCLIParser(os.Args)
+ _, err := cliParser.Parse()
+
+ _ = utils.HandleErrorOrDie(err,
+ utils.OnSentinelError(types.ErrCLINoArgs, func(err error) error {
+ cliParser.PrintHelp()
+ utils.PrintErrAndExit(text.FgRed, 1, "\nNo arguments provided.")
+ return nil
+ }),
+ utils.OnCustomError(func(err types.CLIUnexpectedArgsError) error {
+ cliParser.PrintHelp()
+ utils.PrintErrAndExit(text.FgRed, 1, "\nUnexpected CLI arguments provided: %v", err.Args)
+ return nil
+ }),
+ utils.OnCustomError(func(err types.FieldParseErrors) error {
+ cliParser.PrintHelp()
+ fmt.Println()
+ printValidationErrors("CLI", err.Errors...)
+ fmt.Println()
+ os.Exit(1)
+ return nil
+ }),
+ )
+}
+
+func printValidationErrors(parserName string, errors ...types.FieldParseError) {
+ for _, fieldErr := range errors {
+ utils.PrintErr(text.FgRed, "[%s] Field '%s': %v", parserName, fieldErr.Field, fieldErr.Err)
+ }
+}
diff --git a/config.json b/config.json
deleted file mode 100644
index 66eb6d1..0000000
--- a/config.json
+++ /dev/null
@@ -1,37 +0,0 @@
-{
- "method": "GET",
- "url": "https://example.com",
- "yes": false,
- "timeout": "5s",
- "dodos": 8,
- "requests": 1000,
- "duration": "10s",
- "skip_verify": false,
-
- "params": [
- { "key1": ["value1", "value2", "value3", "value4"] },
- { "key1": "value" },
- { "key2": ["value1", "value2"] }
- ],
-
- "headers": [
- { "key1": ["value1", "value2", "value3", "value4"] },
- { "key1": "value" },
- { "key2": ["value1", "value2"] }
- ],
-
- "cookies": [
- { "key1": ["value1", "value2", "value3", "value4"] },
- { "key1": "value" },
- { "key2": ["value1", "value2"] }
- ],
-
- "body": ["body-text1", "body-text2", "body-text3"],
-
- "proxy": [
- "http://example.com:8080",
- "http://username:password@example.com:8080",
- "socks5://example.com:8080",
- "socks5h://example.com:8080"
- ]
-}
diff --git a/config.yaml b/config.yaml
deleted file mode 100644
index 78b929d..0000000
--- a/config.yaml
+++ /dev/null
@@ -1,40 +0,0 @@
-method: "GET"
-url: "https://example.com"
-yes: false
-timeout: "5s"
-dodos: 8
-requests: 1000
-duration: "10s"
-skip_verify: false
-
-params:
- - key1: ["value1", "value2", "value3", "value4"]
- - key1: "value"
- - key2: ["value1", "value2"]
-
-headers:
- - key1: ["value1", "value2", "value3", "value4"]
- - key1: "value"
- - key2: ["value1", "value2"]
-
-cookies:
- - key1: ["value1", "value2", "value3", "value4"]
- - key1: "value"
- - key2: ["value1", "value2"]
-
-# body: "body-text"
-# OR
-# A random body value will be selected from the list for each request
-body:
- - "body-text1"
- - "body-text2"
- - "body-text3"
-
-# proxy: "http://example.com:8080"
-# OR
-# A random proxy will be selected from the list for each request
-proxy:
- - "http://example.com:8080"
- - "http://username:password@example.com:8080"
- - "socks5://example.com:8080"
- - "socks5h://example.com:8080"
diff --git a/config/cli.go b/config/cli.go
deleted file mode 100644
index 997829a..0000000
--- a/config/cli.go
+++ /dev/null
@@ -1,188 +0,0 @@
-package config
-
-import (
- "flag"
- "fmt"
- "os"
- "strings"
- "time"
-
- "github.com/aykhans/dodo/types"
- "github.com/aykhans/dodo/utils"
-)
-
-const cliUsageText = `Usage:
- dodo [flags]
-
-Examples:
-
-Simple usage:
- dodo -u https://example.com -o 1m
-
-Usage with config file:
- dodo -f /path/to/config/file/config.json
-
-Usage with all flags:
- dodo -f /path/to/config/file/config.json \
- -u https://example.com -m POST \
- -d 10 -r 1000 -o 3m -t 3s \
- -b "body1" -body "body2" \
- -H "header1:value1" -header "header2:value2" \
- -p "param1=value1" -param "param2=value2" \
- -c "cookie1=value1" -cookie "cookie2=value2" \
- -x "http://proxy.example.com:8080" -proxy "socks5://proxy2.example.com:8080" \
- -skip-verify -y
-
-Flags:
- -h, -help help for dodo
- -v, -version version for dodo
- -y, -yes bool Answer yes to all questions (default %v)
- -f, -config-file string Path to the local config file or http(s) URL of the config file
- -d, -dodos uint Number of dodos(threads) (default %d)
- -r, -requests uint Number of total requests
- -o, -duration Time Maximum duration for the test (e.g. 30s, 1m, 5h)
- -t, -timeout Time Timeout for each request (e.g. 400ms, 15s, 1m10s) (default %v)
- -u, -url string URL for stress testing
- -m, -method string HTTP Method for the request (default %s)
- -b, -body [string] Body for the request (e.g. "body text")
- -p, -param [string] Parameter for the request (e.g. "key1=value1")
- -H, -header [string] Header for the request (e.g. "key1:value1")
- -c, -cookie [string] Cookie for the request (e.g. "key1=value1")
- -x, -proxy [string] Proxy for the request (e.g. "http://proxy.example.com:8080")
- -skip-verify bool Skip SSL/TLS certificate verification (default %v)`
-
-func (config *Config) ReadCLI() (types.ConfigFile, error) {
- flag.Usage = func() {
- fmt.Printf(
- cliUsageText+"\n",
- DefaultYes,
- DefaultDodosCount,
- DefaultTimeout,
- DefaultMethod,
- DefaultSkipVerify,
- )
- }
-
- var (
- version = false
- configFile = ""
- yes = false
- skipVerify = false
- method = ""
- url types.RequestURL
- dodosCount = uint(0)
- requestCount = uint(0)
- timeout time.Duration
- duration time.Duration
- )
-
- {
- flag.BoolVar(&version, "version", false, "Prints the version of the program")
- flag.BoolVar(&version, "v", false, "Prints the version of the program")
-
- flag.StringVar(&configFile, "config-file", "", "Path to the configuration file")
- flag.StringVar(&configFile, "f", "", "Path to the configuration file")
-
- flag.BoolVar(&yes, "yes", false, "Answer yes to all questions")
- flag.BoolVar(&yes, "y", false, "Answer yes to all questions")
-
- flag.BoolVar(&skipVerify, "skip-verify", false, "Skip SSL/TLS certificate verification")
-
- flag.StringVar(&method, "method", "", "HTTP Method")
- flag.StringVar(&method, "m", "", "HTTP Method")
-
- flag.Var(&url, "url", "URL to send the request")
- flag.Var(&url, "u", "URL to send the request")
-
- flag.UintVar(&dodosCount, "dodos", 0, "Number of dodos(threads)")
- flag.UintVar(&dodosCount, "d", 0, "Number of dodos(threads)")
-
- flag.UintVar(&requestCount, "requests", 0, "Number of total requests")
- flag.UintVar(&requestCount, "r", 0, "Number of total requests")
-
- flag.DurationVar(&duration, "duration", 0, "Maximum duration of the test")
- flag.DurationVar(&duration, "o", 0, "Maximum duration of the test")
-
- flag.DurationVar(&timeout, "timeout", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
- flag.DurationVar(&timeout, "t", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
-
- flag.Var(&config.Params, "param", "URL parameter to send with the request")
- flag.Var(&config.Params, "p", "URL parameter to send with the request")
-
- flag.Var(&config.Headers, "header", "Header to send with the request")
- flag.Var(&config.Headers, "H", "Header to send with the request")
-
- flag.Var(&config.Cookies, "cookie", "Cookie to send with the request")
- flag.Var(&config.Cookies, "c", "Cookie to send with the request")
-
- flag.Var(&config.Body, "body", "Body to send with the request")
- flag.Var(&config.Body, "b", "Body to send with the request")
-
- flag.Var(&config.Proxies, "proxy", "Proxy to use for the request")
- flag.Var(&config.Proxies, "x", "Proxy to use for the request")
- }
-
- flag.Parse()
-
- if len(os.Args) <= 1 {
- flag.CommandLine.Usage()
- os.Exit(0)
- }
-
- if args := flag.Args(); len(args) > 0 {
- return types.ConfigFile(configFile), fmt.Errorf("unexpected arguments: %v", strings.Join(args, ", "))
- }
-
- if version {
- fmt.Printf("dodo version %s\n", VERSION)
- os.Exit(0)
- }
-
- flag.Visit(func(f *flag.Flag) {
- switch f.Name {
- case "method", "m":
- config.Method = utils.ToPtr(method)
- case "url", "u":
- config.URL = utils.ToPtr(url)
- case "dodos", "d":
- config.DodosCount = utils.ToPtr(dodosCount)
- case "requests", "r":
- config.RequestCount = utils.ToPtr(requestCount)
- case "duration", "o":
- config.Duration = &types.Duration{Duration: duration}
- case "timeout", "t":
- config.Timeout = &types.Timeout{Duration: timeout}
- case "yes", "y":
- config.Yes = utils.ToPtr(yes)
- case "skip-verify":
- config.SkipVerify = utils.ToPtr(skipVerify)
- }
- })
-
- return types.ConfigFile(configFile), nil
-}
-
-// CLIYesOrNoReader reads a yes or no answer from the command line.
-// It prompts the user with the given message and default value,
-// and returns true if the user answers "y" or "Y", and false otherwise.
-// If there is an error while reading the input, it returns false.
-// If the user simply presses enter without providing any input,
-// it returns the default value specified by the `dft` parameter.
-func CLIYesOrNoReader(message string, dft bool) bool {
- var answer string
- defaultMessage := "Y/n"
- if !dft {
- defaultMessage = "y/N"
- }
- fmt.Printf("%s [%s]: ", message, defaultMessage)
- if _, err := fmt.Scanln(&answer); err != nil {
- if err.Error() == "unexpected newline" {
- return dft
- }
- return false
- }
- if answer == "" {
- return dft
- }
- return answer == "y" || answer == "Y"
-}
diff --git a/config/config.go b/config/config.go
deleted file mode 100644
index f333966..0000000
--- a/config/config.go
+++ /dev/null
@@ -1,364 +0,0 @@
-package config
-
-import (
- "bytes"
- "errors"
- "fmt"
- "math/rand"
- "net/url"
- "os"
- "slices"
- "strings"
- "text/template"
- "time"
-
- "github.com/aykhans/dodo/types"
- "github.com/aykhans/dodo/utils"
- "github.com/jedib0t/go-pretty/v6/table"
-)
-
-const (
- VERSION string = "0.7.3"
- DefaultUserAgent string = "Dodo/" + VERSION
- DefaultMethod string = "GET"
- DefaultTimeout time.Duration = time.Second * 10
- DefaultDodosCount uint = 1
- DefaultRequestCount uint = 0
- DefaultDuration time.Duration = 0
- DefaultYes bool = false
- DefaultSkipVerify bool = false
-)
-
-var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"}
-
-type RequestConfig struct {
- Method string
- URL url.URL
- Timeout time.Duration
- DodosCount uint
- RequestCount uint
- Duration time.Duration
- Yes bool
- SkipVerify bool
- Params types.Params
- Headers types.Headers
- Cookies types.Cookies
- Body types.Body
- Proxies types.Proxies
-}
-
-func NewRequestConfig(conf *Config) *RequestConfig {
- return &RequestConfig{
- Method: *conf.Method,
- URL: conf.URL.URL,
- Timeout: conf.Timeout.Duration,
- DodosCount: *conf.DodosCount,
- RequestCount: *conf.RequestCount,
- Duration: conf.Duration.Duration,
- Yes: *conf.Yes,
- SkipVerify: *conf.SkipVerify,
- Params: conf.Params,
- Headers: conf.Headers,
- Cookies: conf.Cookies,
- Body: conf.Body,
- Proxies: conf.Proxies,
- }
-}
-
-func (rc *RequestConfig) GetValidDodosCountForRequests() uint {
- if rc.RequestCount == 0 {
- return rc.DodosCount
- }
- return min(rc.DodosCount, rc.RequestCount)
-}
-
-func (rc *RequestConfig) GetMaxConns(minConns uint) uint {
- maxConns := max(
- minConns, rc.GetValidDodosCountForRequests(),
- )
- return ((maxConns * 50 / 100) + maxConns)
-}
-
-func (rc *RequestConfig) Print() {
- t := table.NewWriter()
- t.SetOutputMirror(os.Stdout)
- t.SetStyle(table.StyleLight)
- t.SetColumnConfigs([]table.ColumnConfig{
- {
- Number: 2,
- WidthMaxEnforcer: func(col string, maxLen int) string {
- lines := strings.Split(col, "\n")
- for i, line := range lines {
- if len(line) > maxLen {
- lines[i] = line[:maxLen-3] + "..."
- }
- }
- return strings.Join(lines, "\n")
- },
- WidthMax: 50},
- })
-
- t.AppendHeader(table.Row{"Request Configuration"})
- t.AppendRow(table.Row{"URL", rc.URL.String()})
- t.AppendSeparator()
- t.AppendRow(table.Row{"Method", rc.Method})
- t.AppendSeparator()
- t.AppendRow(table.Row{"Timeout", rc.Timeout})
- t.AppendSeparator()
- t.AppendRow(table.Row{"Dodos", rc.DodosCount})
- t.AppendSeparator()
- if rc.RequestCount > 0 {
- t.AppendRow(table.Row{"Requests", rc.RequestCount})
- } else {
- t.AppendRow(table.Row{"Requests"})
- }
- t.AppendSeparator()
- if rc.Duration > 0 {
- t.AppendRow(table.Row{"Duration", rc.Duration})
- } else {
- t.AppendRow(table.Row{"Duration"})
- }
- t.AppendSeparator()
- t.AppendRow(table.Row{"Params", rc.Params.String()})
- t.AppendSeparator()
- t.AppendRow(table.Row{"Headers", rc.Headers.String()})
- t.AppendSeparator()
- t.AppendRow(table.Row{"Cookies", rc.Cookies.String()})
- t.AppendSeparator()
- t.AppendRow(table.Row{"Proxy", rc.Proxies.String()})
- t.AppendSeparator()
- t.AppendRow(table.Row{"Body", rc.Body.String()})
- t.AppendSeparator()
- t.AppendRow(table.Row{"Skip Verify", rc.SkipVerify})
-
- t.Render()
-}
-
-type Config struct {
- Method *string `json:"method" yaml:"method"`
- URL *types.RequestURL `json:"url" yaml:"url"`
- Timeout *types.Timeout `json:"timeout" yaml:"timeout"`
- DodosCount *uint `json:"dodos" yaml:"dodos"`
- RequestCount *uint `json:"requests" yaml:"requests"`
- Duration *types.Duration `json:"duration" yaml:"duration"`
- Yes *bool `json:"yes" yaml:"yes"`
- SkipVerify *bool `json:"skip_verify" yaml:"skip_verify"`
- Params types.Params `json:"params" yaml:"params"`
- Headers types.Headers `json:"headers" yaml:"headers"`
- Cookies types.Cookies `json:"cookies" yaml:"cookies"`
- Body types.Body `json:"body" yaml:"body"`
- Proxies types.Proxies `json:"proxy" yaml:"proxy"`
-}
-
-func NewConfig() *Config {
- return &Config{}
-}
-
-func (config *Config) Validate() []error {
- var errs []error
- if utils.IsNilOrZero(config.URL) {
- errs = append(errs, errors.New("request URL is required"))
- } else {
- if config.URL.Scheme != "http" && config.URL.Scheme != "https" {
- errs = append(errs, errors.New("request URL scheme must be http or https"))
- }
-
- urlParams := types.Params{}
- for key, values := range config.URL.Query() {
- for _, value := range values {
- urlParams = append(urlParams, types.KeyValue[string, []string]{
- Key: key,
- Value: []string{value},
- })
- }
- }
- config.Params = append(urlParams, config.Params...)
- config.URL.RawQuery = ""
- }
-
- if utils.IsNilOrZero(config.Method) {
- errs = append(errs, errors.New("request method is required"))
- }
- if utils.IsNilOrZero(config.Timeout) {
- errs = append(errs, errors.New("request timeout must be greater than 0"))
- }
- if utils.IsNilOrZero(config.DodosCount) {
- errs = append(errs, errors.New("dodos count must be greater than 0"))
- }
- if utils.IsNilOrZero(config.Duration) && utils.IsNilOrZero(config.RequestCount) {
- errs = append(errs, errors.New("you should provide at least one of duration or request count"))
- }
-
- for i, proxy := range config.Proxies {
- if proxy.String() == "" {
- errs = append(errs, fmt.Errorf("proxies[%d]: proxy cannot be empty", i))
- } else if schema := proxy.Scheme; !slices.Contains(SupportedProxySchemes, schema) {
- errs = append(errs,
- fmt.Errorf("proxies[%d]: proxy has unsupported scheme \"%s\" (supported schemes: %s)",
- i, proxy.String(), strings.Join(SupportedProxySchemes, ", "),
- ),
- )
- }
- }
-
- funcMap := *utils.NewFuncMapGenerator(
- rand.New(
- rand.NewSource(
- time.Now().UnixNano(),
- ),
- ),
- ).GetFuncMap()
-
- for _, header := range config.Headers {
- t, err := template.New("default").Funcs(funcMap).Parse(header.Key)
- if err != nil {
- errs = append(errs, fmt.Errorf("header key (%s) parse error: %v", header.Key, err))
- } else {
- var buf bytes.Buffer
- if err = t.Execute(&buf, nil); err != nil {
- errs = append(errs, fmt.Errorf("header key (%s) parse error: %v", header.Key, err))
- }
- }
-
- for _, value := range header.Value {
- t, err := template.New("default").Funcs(funcMap).Parse(value)
- if err != nil {
- errs = append(errs, fmt.Errorf("header value (%s) parse error: %v", value, err))
- } else {
- var buf bytes.Buffer
- if err = t.Execute(&buf, nil); err != nil {
- errs = append(errs, fmt.Errorf("header value (%s) parse error: %v", value, err))
- }
- }
- }
- }
-
- for _, cookie := range config.Cookies {
- t, err := template.New("default").Funcs(funcMap).Parse(cookie.Key)
- if err != nil {
- errs = append(errs, fmt.Errorf("cookie key (%s) parse error: %v", cookie.Key, err))
- } else {
- var buf bytes.Buffer
- if err = t.Execute(&buf, nil); err != nil {
- errs = append(errs, fmt.Errorf("cookie key (%s) parse error: %v", cookie.Key, err))
- }
- }
-
- for _, value := range cookie.Value {
- t, err := template.New("default").Funcs(funcMap).Parse(value)
- if err != nil {
- errs = append(errs, fmt.Errorf("cookie value (%s) parse error: %v", value, err))
- } else {
- var buf bytes.Buffer
- if err = t.Execute(&buf, nil); err != nil {
- errs = append(errs, fmt.Errorf("cookie value (%s) parse error: %v", value, err))
- }
- }
- }
- }
-
- for _, param := range config.Params {
- t, err := template.New("default").Funcs(funcMap).Parse(param.Key)
- if err != nil {
- errs = append(errs, fmt.Errorf("param key (%s) parse error: %v", param.Key, err))
- } else {
- var buf bytes.Buffer
- if err = t.Execute(&buf, nil); err != nil {
- errs = append(errs, fmt.Errorf("param key (%s) parse error: %v", param.Key, err))
- }
- }
-
- for _, value := range param.Value {
- t, err := template.New("default").Funcs(funcMap).Parse(value)
- if err != nil {
- errs = append(errs, fmt.Errorf("param value (%s) parse error: %v", value, err))
- } else {
- var buf bytes.Buffer
- if err = t.Execute(&buf, nil); err != nil {
- errs = append(errs, fmt.Errorf("param value (%s) parse error: %v", value, err))
- }
- }
- }
- }
-
- for _, body := range config.Body {
- t, err := template.New("default").Funcs(funcMap).Parse(body)
- if err != nil {
- errs = append(errs, fmt.Errorf("body (%s) parse error: %v", body, err))
- } else {
- var buf bytes.Buffer
- if err = t.Execute(&buf, nil); err != nil {
- errs = append(errs, fmt.Errorf("body (%s) parse error: %v", body, err))
- }
- }
- }
-
- return errs
-}
-
-func (config *Config) MergeConfig(newConfig *Config) {
- if newConfig.Method != nil {
- config.Method = newConfig.Method
- }
- if newConfig.URL != nil {
- config.URL = newConfig.URL
- }
- if newConfig.Timeout != nil {
- config.Timeout = newConfig.Timeout
- }
- if newConfig.DodosCount != nil {
- config.DodosCount = newConfig.DodosCount
- }
- if newConfig.RequestCount != nil {
- config.RequestCount = newConfig.RequestCount
- }
- if newConfig.Duration != nil {
- config.Duration = newConfig.Duration
- }
- if newConfig.Yes != nil {
- config.Yes = newConfig.Yes
- }
- if newConfig.SkipVerify != nil {
- config.SkipVerify = newConfig.SkipVerify
- }
- if len(newConfig.Params) != 0 {
- config.Params = newConfig.Params
- }
- if len(newConfig.Headers) != 0 {
- config.Headers = newConfig.Headers
- }
- if len(newConfig.Cookies) != 0 {
- config.Cookies = newConfig.Cookies
- }
- if len(newConfig.Body) != 0 {
- config.Body = newConfig.Body
- }
- if len(newConfig.Proxies) != 0 {
- config.Proxies = newConfig.Proxies
- }
-}
-
-func (config *Config) SetDefaults() {
- if config.Method == nil {
- config.Method = utils.ToPtr(DefaultMethod)
- }
- if config.Timeout == nil {
- config.Timeout = &types.Timeout{Duration: DefaultTimeout}
- }
- if config.DodosCount == nil {
- config.DodosCount = utils.ToPtr(DefaultDodosCount)
- }
- if config.RequestCount == nil {
- config.RequestCount = utils.ToPtr(DefaultRequestCount)
- }
- if config.Duration == nil {
- config.Duration = &types.Duration{Duration: DefaultDuration}
- }
- if config.Yes == nil {
- config.Yes = utils.ToPtr(DefaultYes)
- }
- if config.SkipVerify == nil {
- config.SkipVerify = utils.ToPtr(DefaultSkipVerify)
- }
- config.Headers.SetIfNotExists("User-Agent", DefaultUserAgent)
-}
diff --git a/config/file.go b/config/file.go
deleted file mode 100644
index abf5e21..0000000
--- a/config/file.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package config
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "os"
- "slices"
- "strings"
- "time"
-
- "github.com/aykhans/dodo/types"
- "gopkg.in/yaml.v3"
-)
-
-var supportedFileTypes = []string{"json", "yaml", "yml"}
-
-func (config *Config) ReadFile(filePath types.ConfigFile) error {
- var (
- data []byte
- err error
- )
-
- fileExt := filePath.Extension()
- if slices.Contains(supportedFileTypes, fileExt) {
- if filePath.LocationType() == types.FileLocationTypeRemoteHTTP {
- client := &http.Client{
- Timeout: 10 * time.Second,
- }
-
- resp, err := client.Get(filePath.String())
- if err != nil {
- return fmt.Errorf("failed to fetch config file from %s", filePath)
- }
- defer func() { _ = resp.Body.Close() }()
-
- data, err = io.ReadAll(io.Reader(resp.Body))
- if err != nil {
- return fmt.Errorf("failed to read config file from %s", filePath)
- }
- } else {
- data, err = os.ReadFile(filePath.String())
- if err != nil {
- return errors.New("failed to read config file from " + filePath.String())
- }
- }
-
- switch fileExt {
- case "json":
- return parseJSONConfig(data, config)
- case "yml", "yaml":
- return parseYAMLConfig(data, config)
- }
- }
-
- return fmt.Errorf("unsupported config file type (supported types: %v)", strings.Join(supportedFileTypes, ", "))
-}
-
-func parseJSONConfig(data []byte, config *Config) error {
- err := json.Unmarshal(data, &config)
- if err != nil {
- switch parsedErr := err.(type) {
- case *json.SyntaxError:
- return fmt.Errorf("JSON Config file: invalid syntax at byte offset %d", parsedErr.Offset)
- case *json.UnmarshalTypeError:
- return fmt.Errorf("JSON Config file: invalid type %v for field %s, expected %v", parsedErr.Value, parsedErr.Field, parsedErr.Type)
- default:
- return fmt.Errorf("JSON Config file: %s", err.Error())
- }
- }
-
- return nil
-}
-
-func parseYAMLConfig(data []byte, config *Config) error {
- err := yaml.Unmarshal(data, &config)
- if err != nil {
- return fmt.Errorf("YAML Config file: %s", err.Error())
- }
-
- return nil
-}
diff --git a/go.mod b/go.mod
index 58e414e..71398e3 100644
--- a/go.mod
+++ b/go.mod
@@ -2,21 +2,18 @@ module github.com/aykhans/dodo
go 1.25
+require github.com/stretchr/testify v1.10.0
+
require (
- github.com/brianvoe/gofakeit/v7 v7.3.0
- github.com/jedib0t/go-pretty/v6 v6.6.8
- github.com/valyala/fasthttp v1.65.0
- gopkg.in/yaml.v3 v3.0.1
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ golang.org/x/sys v0.30.0 // indirect
+ golang.org/x/text v0.22.0 // indirect
)
require (
- github.com/andybalholm/brotli v1.2.0 // indirect
- github.com/klauspost/compress v1.18.0 // indirect
- github.com/mattn/go-runewidth v0.0.16 // indirect
- github.com/rivo/uniseg v0.4.7 // indirect
- github.com/valyala/bytebufferpool v1.0.0 // indirect
- golang.org/x/net v0.43.0 // indirect
- golang.org/x/sys v0.35.0 // indirect
- golang.org/x/term v0.34.0 // indirect
- golang.org/x/text v0.28.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/jedib0t/go-pretty/v6 v6.6.8
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 3668d1f..42dbb42 100644
--- a/go.sum
+++ b/go.sum
@@ -1,13 +1,7 @@
-github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
-github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
-github.com/brianvoe/gofakeit/v7 v7.3.0 h1:TWStf7/lLpAjKw+bqwzeORo9jvrxToWEwp9b1J2vApQ=
-github.com/brianvoe/gofakeit/v7 v7.3.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jedib0t/go-pretty/v6 v6.6.8 h1:JnnzQeRz2bACBobIaa/r+nqjvws4yEhcmaZ4n1QzsEc=
github.com/jedib0t/go-pretty/v6 v6.6.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
-github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
-github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -17,20 +11,10 @@ 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-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.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
-github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
-github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
-github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
-golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
-golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
-golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
-golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
-golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
-golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
-golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
+golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/main.go b/main.go
deleted file mode 100644
index 5e99a86..0000000
--- a/main.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package main
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "os/signal"
- "syscall"
-
- "github.com/aykhans/dodo/config"
- "github.com/aykhans/dodo/requests"
- "github.com/aykhans/dodo/types"
- "github.com/aykhans/dodo/utils"
- "github.com/jedib0t/go-pretty/v6/text"
-)
-
-func main() {
- conf := config.NewConfig()
- configFile, err := conf.ReadCLI()
- if err != nil {
- utils.PrintErrAndExit(err)
- }
-
- if configFile.String() != "" {
- tempConf := config.NewConfig()
- if err := tempConf.ReadFile(configFile); err != nil {
- utils.PrintErrAndExit(err)
- }
- tempConf.MergeConfig(conf)
- conf = tempConf
- }
- conf.SetDefaults()
-
- if errs := conf.Validate(); len(errs) > 0 {
- utils.PrintErrAndExit(errors.Join(errs...))
- }
-
- requestConf := config.NewRequestConfig(conf)
- requestConf.Print()
-
- if !requestConf.Yes {
- response := config.CLIYesOrNoReader("Do you want to continue?", false)
- if !response {
- utils.PrintAndExit("Exiting...\n")
- }
- }
-
- ctx, cancel := context.WithCancel(context.Background())
- go listenForTermination(func() { cancel() })
-
- responses, err := requests.Run(ctx, requestConf)
- if err != nil {
- if err == types.ErrInterrupt {
- fmt.Println(text.FgYellow.Sprint(err.Error()))
- return
- }
- utils.PrintErrAndExit(err)
- }
-
- responses.Print()
-}
-
-func listenForTermination(do func()) {
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
- <-sigChan
- do()
-}
diff --git a/pkg/config/cli.go b/pkg/config/cli.go
new file mode 100644
index 0000000..d741a4f
--- /dev/null
+++ b/pkg/config/cli.go
@@ -0,0 +1,250 @@
+package config
+
+import (
+ "flag"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/aykhans/dodo/pkg/types"
+ "github.com/aykhans/dodo/pkg/utils"
+)
+
+const cliUsageText = `Usage:
+ dodo [flags]
+
+Examples:
+
+Simple usage:
+ dodo -u https://example.com -o 1m
+
+Usage with config file:
+ dodo -f /path/to/config/file/config.json
+
+Usage with all flags:
+ dodo -f /path/to/config/file/config.json \
+ -u https://example.com -m POST \
+ -d 10 -r 1000 -o 3m -t 3s \
+ -b "body1" -body "body2" \
+ -H "header1:value1" -header "header2:value2" \
+ -p "param1=value1" -param "param2=value2" \
+ -c "cookie1=value1" -cookie "cookie2=value2" \
+ -x "http://proxy.example.com:8080" -proxy "socks5://proxy2.example.com:8080" \
+ -skip-verify -y
+
+Flags:
+ -h, -help help for dodo
+ -v, -version version for dodo
+ -y, -yes bool Answer yes to all questions (default %v)
+ -f, -config-file string Path to the local config file or http(s) URL of the config file
+ -d, -dodos uint Number of dodos(threads) (default %d)
+ -r, -requests uint Number of total requests
+ -o, -duration Time Maximum duration for the test (e.g. 30s, 1m, 5h)
+ -t, -timeout Time Timeout for each request (e.g. 400ms, 15s, 1m10s) (default %v)
+ -u, -url string URL for stress testing
+ -m, -method string HTTP Method for the request (default %s)
+ -b, -body [string] Body for the request (e.g. "body text")
+ -p, -param [string] Parameter for the request (e.g. "key1=value1")
+ -H, -header [string] Header for the request (e.g. "key1: value1")
+ -c, -cookie [string] Cookie for the request (e.g. "key1=value1")
+ -x, -proxy [string] Proxy for the request (e.g. "http://proxy.example.com:8080")
+ -skip-verify bool Skip SSL/TLS certificate verification (default %v)`
+
+type ConfigCLIParser struct {
+ args []string
+}
+
+func NewConfigCLIParser(args []string) *ConfigCLIParser {
+ if args == nil {
+ args = []string{}
+ }
+ return &ConfigCLIParser{args}
+}
+
+type stringSliceArg []string
+
+func (arg *stringSliceArg) String() string {
+ return strings.Join(*arg, ",")
+}
+
+func (arg *stringSliceArg) Set(value string) error {
+ *arg = append(*arg, value)
+ return nil
+}
+
+// Parse parses command-line arguments into a Config object.
+// It can return the following errors:
+// - types.ErrCLINoArgs
+// - types.CLIUnexpectedArgsError
+// - types.FieldParseErrors
+func (parser *ConfigCLIParser) Parse() (*Config, error) {
+ flagSet := flag.NewFlagSet("dodo", flag.ExitOnError)
+
+ flagSet.Usage = func() { parser.PrintHelp() }
+
+ var (
+ config = &Config{}
+ yes bool
+ skipVerify bool
+ method string
+ urlInput string
+ dodosCount uint
+ requestCount uint
+ duration time.Duration
+ timeout time.Duration
+ params = stringSliceArg{}
+ headers = stringSliceArg{}
+ cookies = stringSliceArg{}
+ bodies = stringSliceArg{}
+ proxies = stringSliceArg{}
+ )
+
+ {
+ flagSet.BoolVar(&yes, "yes", false, "Answer yes to all questions")
+ flagSet.BoolVar(&yes, "y", false, "Answer yes to all questions")
+
+ flagSet.BoolVar(&skipVerify, "skip-verify", false, "Skip SSL/TLS certificate verification")
+
+ flagSet.StringVar(&method, "method", "", "HTTP Method")
+ flagSet.StringVar(&method, "m", "", "HTTP Method")
+
+ flagSet.StringVar(&urlInput, "url", "", "URL to send the request")
+ flagSet.StringVar(&urlInput, "u", "", "URL to send the request")
+
+ flagSet.UintVar(&dodosCount, "dodos", 0, "Number of dodos(threads)")
+ flagSet.UintVar(&dodosCount, "d", 0, "Number of dodos(threads)")
+
+ flagSet.UintVar(&requestCount, "requests", 0, "Number of total requests")
+ flagSet.UintVar(&requestCount, "r", 0, "Number of total requests")
+
+ flagSet.DurationVar(&duration, "duration", 0, "Maximum duration of the test")
+ flagSet.DurationVar(&duration, "o", 0, "Maximum duration of the test")
+
+ flagSet.DurationVar(&timeout, "timeout", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
+ flagSet.DurationVar(&timeout, "t", 0, "Timeout for each request (e.g. 400ms, 15s, 1m10s)")
+
+ flagSet.Var(¶ms, "param", "URL parameter to send with the request")
+ flagSet.Var(¶ms, "p", "URL parameter to send with the request")
+
+ flagSet.Var(&headers, "header", "Header to send with the request")
+ flagSet.Var(&headers, "H", "Header to send with the request")
+
+ flagSet.Var(&cookies, "cookie", "Cookie to send with the request")
+ flagSet.Var(&cookies, "c", "Cookie to send with the request")
+
+ flagSet.Var(&bodies, "body", "Body to send with the request")
+ flagSet.Var(&bodies, "b", "Body to send with the request")
+
+ flagSet.Var(&proxies, "proxy", "Proxy to use for the request")
+ flagSet.Var(&proxies, "x", "Proxy to use for the request")
+ }
+
+ // Parse the specific arguments provided to the parser, skipping the program name.
+ if err := flagSet.Parse(parser.args[1:]); err != nil {
+ panic(err)
+ }
+
+ // Check if no flags were set and no non-flag arguments were provided.
+ // This covers cases where `dodo` is run without any meaningful arguments.
+ if flagSet.NFlag() == 0 && len(flagSet.Args()) == 0 {
+ return nil, types.ErrCLINoArgs
+ }
+
+ // Check for any unexpected non-flag arguments remaining after parsing.
+ if args := flagSet.Args(); len(args) > 0 {
+ return nil, types.NewCLIUnexpectedArgsError(args)
+ }
+
+ var fieldParseErrors []types.FieldParseError
+ // Iterate over flags that were explicitly set on the command line.
+ flagSet.Visit(func(flagVar *flag.Flag) {
+ switch flagVar.Name {
+ case "yes", "y":
+ config.Yes = utils.ToPtr(yes)
+ case "skip-verify":
+ config.SkipVerify = utils.ToPtr(skipVerify)
+ case "method", "m":
+ config.Method = utils.ToPtr(method)
+ case "url", "u":
+ urlParsed, err := url.Parse(urlInput)
+ if err != nil {
+ fieldParseErrors = append(fieldParseErrors, *types.NewFieldParseError("url", err))
+ } else {
+ config.URL = urlParsed
+ }
+ case "dodos", "d":
+ config.DodosCount = utils.ToPtr(dodosCount)
+ case "requests", "r":
+ config.RequestCount = utils.ToPtr(requestCount)
+ case "duration", "o":
+ config.Duration = utils.ToPtr(duration)
+ case "timeout", "t":
+ config.Timeout = utils.ToPtr(timeout)
+ case "param", "p":
+ config.Params.Parse(params...)
+ case "header", "H":
+ config.Headers.Parse(headers...)
+ case "cookie", "c":
+ config.Cookies.Parse(cookies...)
+ case "body", "b":
+ config.Bodies.Parse(bodies...)
+ case "proxy", "x":
+ for i, proxy := range proxies {
+ err := config.Proxies.Parse(proxy)
+ if err != nil {
+ fieldParseErrors = append(
+ fieldParseErrors,
+ *types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), err),
+ )
+ }
+ }
+ }
+ })
+
+ if len(fieldParseErrors) > 0 {
+ return nil, types.NewFieldParseErrors(fieldParseErrors)
+ }
+
+ return config, nil
+}
+
+func (parser *ConfigCLIParser) PrintHelp() {
+ fmt.Printf(
+ cliUsageText+"\n",
+ Defaults.Yes,
+ Defaults.DodosCount,
+ Defaults.RequestTimeout,
+ Defaults.Method,
+ Defaults.SkipVerify,
+ )
+}
+
+// CLIYesOrNoReader reads a yes or no answer from the command line.
+// It prompts the user with the given message and default value,
+// and returns true if the user answers "y" or "Y", and false otherwise.
+// If there is an error while reading the input, it returns false.
+// If the user simply presses enter without providing any input,
+// it returns the default value specified by the `def` parameter.
+func CLIYesOrNoReader(message string, def bool) bool {
+ var answer string
+ defaultMessage := "Y/n"
+
+ if !def {
+ defaultMessage = "y/N"
+ }
+
+ fmt.Printf("%s [%s]: ", message, defaultMessage)
+ if _, err := fmt.Scanln(&answer); err != nil {
+ if err.Error() == "unexpected newline" {
+ return def
+ }
+ return false
+ }
+
+ if answer == "" {
+ return def
+ }
+
+ return answer == "y" || answer == "Y"
+}
diff --git a/pkg/config/cli_test.go b/pkg/config/cli_test.go
new file mode 100644
index 0000000..e165c4e
--- /dev/null
+++ b/pkg/config/cli_test.go
@@ -0,0 +1,679 @@
+package config
+
+import (
+ "bytes"
+ "io"
+ "net/url"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/aykhans/dodo/pkg/types"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewConfigCLIParser(t *testing.T) {
+ t.Run("NewConfigCLIParser with valid args", func(t *testing.T) {
+ args := []string{"dodo", "-u", "https://example.com"}
+ parser := NewConfigCLIParser(args)
+
+ require.NotNil(t, parser)
+ assert.Equal(t, args, parser.args)
+ })
+
+ t.Run("NewConfigCLIParser with nil args", func(t *testing.T) {
+ parser := NewConfigCLIParser(nil)
+
+ require.NotNil(t, parser)
+ assert.Equal(t, []string{}, parser.args)
+ })
+
+ t.Run("NewConfigCLIParser with empty args", func(t *testing.T) {
+ args := []string{}
+ parser := NewConfigCLIParser(args)
+
+ require.NotNil(t, parser)
+ assert.Equal(t, args, parser.args)
+ })
+}
+
+func TestStringSliceArg(t *testing.T) {
+ t.Run("stringSliceArg String method", func(t *testing.T) {
+ arg := stringSliceArg{"value1", "value2", "value3"}
+ assert.Equal(t, "value1,value2,value3", arg.String())
+ })
+
+ t.Run("stringSliceArg String with empty slice", func(t *testing.T) {
+ arg := stringSliceArg{}
+ assert.Empty(t, arg.String())
+ })
+
+ t.Run("stringSliceArg String with single value", func(t *testing.T) {
+ arg := stringSliceArg{"single"}
+ assert.Equal(t, "single", arg.String())
+ })
+
+ t.Run("stringSliceArg Set method", func(t *testing.T) {
+ arg := &stringSliceArg{}
+
+ err := arg.Set("first")
+ require.NoError(t, err)
+ assert.Equal(t, stringSliceArg{"first"}, *arg)
+
+ err = arg.Set("second")
+ require.NoError(t, err)
+ assert.Equal(t, stringSliceArg{"first", "second"}, *arg)
+ })
+
+ t.Run("stringSliceArg Set with empty string", func(t *testing.T) {
+ arg := &stringSliceArg{}
+
+ err := arg.Set("")
+ require.NoError(t, err)
+ assert.Equal(t, stringSliceArg{""}, *arg)
+ })
+}
+
+func TestConfigCLIParser_Parse(t *testing.T) {
+ t.Run("Parse with no arguments returns ErrCLINoArgs", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo"})
+ config, err := parser.Parse()
+
+ assert.Nil(t, config)
+ require.ErrorIs(t, err, types.ErrCLINoArgs)
+ })
+
+ t.Run("Parse with unexpected arguments returns CLIUnexpectedArgsError", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "unexpected", "args"})
+ config, err := parser.Parse()
+
+ assert.Nil(t, config)
+ var cliErr types.CLIUnexpectedArgsError
+ require.ErrorAs(t, err, &cliErr)
+ assert.Equal(t, []string{"unexpected", "args"}, cliErr.Args)
+ })
+
+ t.Run("Parse with valid URL", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-u", "https://example.com"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.URL)
+ assert.Equal(t, "https://example.com", config.URL.String())
+ })
+
+ t.Run("Parse with invalid URL returns FieldParseErrors", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-u", "://invalid-url"})
+ config, err := parser.Parse()
+
+ assert.Nil(t, config)
+ var fieldErr types.FieldParseErrors
+ require.ErrorAs(t, err, &fieldErr)
+ assert.Len(t, fieldErr.Errors, 1)
+ assert.Equal(t, "url", fieldErr.Errors[0].Field)
+ })
+
+ t.Run("Parse with method flag", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-m", "POST"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.Method)
+ assert.Equal(t, "POST", *config.Method)
+ })
+
+ t.Run("Parse with yes flag", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-y"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.Yes)
+ assert.True(t, *config.Yes)
+ })
+
+ t.Run("Parse with skip-verify flag", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-skip-verify"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.SkipVerify)
+ assert.True(t, *config.SkipVerify)
+ })
+
+ t.Run("Parse with dodos count", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-d", "5"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.DodosCount)
+ assert.Equal(t, uint(5), *config.DodosCount)
+ })
+
+ t.Run("Parse with request count", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-r", "1000"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.RequestCount)
+ assert.Equal(t, uint(1000), *config.RequestCount)
+ })
+
+ t.Run("Parse with duration", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-o", "5m"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.Duration)
+ assert.Equal(t, 5*time.Minute, *config.Duration)
+ })
+
+ t.Run("Parse with timeout", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-t", "30s"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.Timeout)
+ assert.Equal(t, 30*time.Second, *config.Timeout)
+ })
+
+ t.Run("Parse with parameters", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-p", "key1=value1", "-p", "key2=value2"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ assert.Len(t, config.Params, 2)
+ assert.Equal(t, "key1", config.Params[0].Key)
+ assert.Equal(t, []string{"value1"}, config.Params[0].Value)
+ assert.Equal(t, "key2", config.Params[1].Key)
+ assert.Equal(t, []string{"value2"}, config.Params[1].Value)
+ })
+
+ t.Run("Parse with headers", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-H", "Content-Type: application/json", "-H", "Authorization: Bearer token"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ assert.Len(t, config.Headers, 2)
+ assert.Equal(t, "Content-Type", config.Headers[0].Key)
+ assert.Equal(t, []string{"application/json"}, config.Headers[0].Value)
+ assert.Equal(t, "Authorization", config.Headers[1].Key)
+ assert.Equal(t, []string{"Bearer token"}, config.Headers[1].Value)
+ })
+
+ t.Run("Parse with cookies", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-c", "session=abc123", "-c", "user=john"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ assert.Len(t, config.Cookies, 2)
+ assert.Equal(t, "session", config.Cookies[0].Key)
+ assert.Equal(t, []string{"abc123"}, config.Cookies[0].Value)
+ assert.Equal(t, "user", config.Cookies[1].Key)
+ assert.Equal(t, []string{"john"}, config.Cookies[1].Value)
+ })
+
+ t.Run("Parse with bodies", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-b", "body1", "-b", "body2"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ assert.Len(t, config.Bodies, 2)
+ assert.Equal(t, types.Body("body1"), config.Bodies[0])
+ assert.Equal(t, types.Body("body2"), config.Bodies[1])
+ })
+
+ t.Run("Parse with valid proxies", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-x", "http://proxy1.example.com:8080", "-x", "socks5://proxy2.example.com:1080"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ assert.Len(t, config.Proxies, 2)
+ assert.Equal(t, "http://proxy1.example.com:8080", config.Proxies[0].String())
+ assert.Equal(t, "socks5://proxy2.example.com:1080", config.Proxies[1].String())
+ })
+
+ t.Run("Parse with invalid proxy returns FieldParseErrors", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-x", "://invalid-proxy"})
+ config, err := parser.Parse()
+
+ assert.Nil(t, config)
+ var fieldErr types.FieldParseErrors
+ require.ErrorAs(t, err, &fieldErr)
+ assert.Len(t, fieldErr.Errors, 1)
+ assert.Equal(t, "proxy[0]", fieldErr.Errors[0].Field)
+ })
+
+ t.Run("Parse with mixed valid and invalid proxies", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-x", "http://valid.example.com:8080", "-x", "://invalid"})
+ config, err := parser.Parse()
+
+ assert.Nil(t, config)
+ var fieldErr types.FieldParseErrors
+ require.ErrorAs(t, err, &fieldErr)
+ assert.Len(t, fieldErr.Errors, 1)
+ assert.Equal(t, "proxy[1]", fieldErr.Errors[0].Field)
+ })
+
+ t.Run("Parse with long flag names", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{
+ "dodo",
+ "--url", "https://example.com",
+ "--method", "POST",
+ "--yes",
+ "--skip-verify",
+ "--dodos", "3",
+ "--requests", "500",
+ "--duration", "1m",
+ "--timeout", "10s",
+ })
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ assert.Equal(t, "https://example.com", config.URL.String())
+ assert.Equal(t, "POST", *config.Method)
+ assert.True(t, *config.Yes)
+ assert.True(t, *config.SkipVerify)
+ assert.Equal(t, uint(3), *config.DodosCount)
+ assert.Equal(t, uint(500), *config.RequestCount)
+ assert.Equal(t, time.Minute, *config.Duration)
+ assert.Equal(t, 10*time.Second, *config.Timeout)
+ })
+
+ t.Run("Parse with all flags combined", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{
+ "dodo",
+ "-u", "https://api.example.com/test",
+ "-m", "PUT",
+ "-y",
+ "-skip-verify",
+ "-d", "10",
+ "-r", "2000",
+ "-o", "30m",
+ "-t", "5s",
+ "-p", "apikey=123",
+ "-H", "Content-Type: application/json",
+ "-c", "session=token123",
+ "-b", `{"data": "test"}`,
+ "-x", "http://proxy.example.com:3128",
+ })
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ // Verify all fields are set correctly
+ assert.Equal(t, "https://api.example.com/test", config.URL.String())
+ assert.Equal(t, "PUT", *config.Method)
+ assert.True(t, *config.Yes)
+ assert.True(t, *config.SkipVerify)
+ assert.Equal(t, uint(10), *config.DodosCount)
+ assert.Equal(t, uint(2000), *config.RequestCount)
+ assert.Equal(t, 30*time.Minute, *config.Duration)
+ assert.Equal(t, 5*time.Second, *config.Timeout)
+
+ assert.Len(t, config.Params, 1)
+ assert.Equal(t, "apikey", config.Params[0].Key)
+
+ assert.Len(t, config.Headers, 1)
+ assert.Equal(t, "Content-Type", config.Headers[0].Key)
+
+ assert.Len(t, config.Cookies, 1)
+ assert.Equal(t, "session", config.Cookies[0].Key)
+
+ assert.Len(t, config.Bodies, 1)
+ assert.Equal(t, types.Body(`{"data": "test"}`), config.Bodies[0]) //nolint:testifylint
+
+ assert.Len(t, config.Proxies, 1)
+ assert.Equal(t, "http://proxy.example.com:3128", config.Proxies[0].String())
+ })
+
+ t.Run("Parse with multiple field parse errors", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{
+ "dodo",
+ "-u", "://invalid-url",
+ "-x", "://invalid-proxy1",
+ "-x", "://invalid-proxy2",
+ })
+ config, err := parser.Parse()
+
+ assert.Nil(t, config)
+ var fieldErr types.FieldParseErrors
+ require.ErrorAs(t, err, &fieldErr)
+ assert.Len(t, fieldErr.Errors, 3)
+
+ // Check error fields
+ fields := make(map[string]bool)
+ for _, parseErr := range fieldErr.Errors {
+ fields[parseErr.Field] = true
+ }
+ assert.True(t, fields["url"])
+ assert.True(t, fields["proxy[0]"])
+ assert.True(t, fields["proxy[1]"])
+ })
+}
+
+func TestConfigCLIParser_PrintHelp(t *testing.T) {
+ t.Run("PrintHelp outputs expected content", func(t *testing.T) {
+ // Capture stdout
+ oldStdout := os.Stdout
+ reader, writer, _ := os.Pipe()
+ os.Stdout = writer
+
+ parser := NewConfigCLIParser([]string{"dodo"})
+ parser.PrintHelp()
+
+ // Restore stdout and read output
+ writer.Close()
+ os.Stdout = oldStdout
+ var buf bytes.Buffer
+ io.Copy(&buf, reader)
+ output := buf.String()
+
+ // Verify help text contains expected elements
+ assert.Contains(t, output, "Usage:")
+ assert.Contains(t, output, "dodo [flags]")
+ assert.Contains(t, output, "Examples:")
+ assert.Contains(t, output, "Flags:")
+ assert.Contains(t, output, "-h, -help")
+ assert.Contains(t, output, "-v, -version")
+ assert.Contains(t, output, "-u, -url")
+ assert.Contains(t, output, "-m, -method")
+ assert.Contains(t, output, "-d, -dodos")
+ assert.Contains(t, output, "-r, -requests")
+ assert.Contains(t, output, "-t, -timeout")
+ assert.Contains(t, output, "-b, -body")
+ assert.Contains(t, output, "-H, -header")
+ assert.Contains(t, output, "-p, -param")
+ assert.Contains(t, output, "-c, -cookie")
+ assert.Contains(t, output, "-x, -proxy")
+ assert.Contains(t, output, "-skip-verify")
+ assert.Contains(t, output, "-y, -yes")
+
+ // Verify default values are included
+ assert.Contains(t, output, Defaults.Method)
+ assert.Contains(t, output, "1") // DodosCount default
+ assert.Contains(t, output, "10s") // RequestTimeout default
+ assert.Contains(t, output, "false") // Yes default
+ assert.Contains(t, output, "false") // SkipVerify default
+ })
+}
+
+func TestCLIYesOrNoReader(t *testing.T) {
+ t.Run("CLIYesOrNoReader with 'y' input returns true", func(t *testing.T) {
+ // Redirect stdin
+ oldStdin := os.Stdin
+ reader, writer, _ := os.Pipe()
+ os.Stdin = reader
+
+ // Write input and close writer
+ writer.WriteString("y\n")
+ writer.Close()
+
+ result := CLIYesOrNoReader("Test question", false)
+
+ // Restore stdin
+ os.Stdin = oldStdin
+
+ assert.True(t, result)
+ })
+
+ t.Run("CLIYesOrNoReader with 'Y' input returns true", func(t *testing.T) {
+ // Redirect stdin
+ oldStdin := os.Stdin
+ reader, writer, _ := os.Pipe()
+ os.Stdin = reader
+
+ // Write input and close writer
+ writer.WriteString("Y\n")
+ writer.Close()
+
+ result := CLIYesOrNoReader("Test question", false)
+
+ // Restore stdin
+ os.Stdin = oldStdin
+
+ assert.True(t, result)
+ })
+
+ t.Run("CLIYesOrNoReader with 'n' input returns false", func(t *testing.T) {
+ // Redirect stdin
+ oldStdin := os.Stdin
+ reader, writer, _ := os.Pipe()
+ os.Stdin = reader
+
+ // Write input and close writer
+ writer.WriteString("n\n")
+ writer.Close()
+
+ result := CLIYesOrNoReader("Test question", true)
+
+ // Restore stdin
+ os.Stdin = oldStdin
+
+ assert.False(t, result)
+ })
+
+ t.Run("CLIYesOrNoReader with empty input returns default", func(t *testing.T) {
+ // Redirect stdin
+ oldStdin := os.Stdin
+ reader, writer, _ := os.Pipe()
+ os.Stdin = reader
+
+ // Write just newline and close writer
+ writer.WriteString("\n")
+ writer.Close()
+
+ // Test with default true
+ result := CLIYesOrNoReader("Test question", true)
+ os.Stdin = oldStdin
+ assert.True(t, result)
+ })
+
+ t.Run("CLIYesOrNoReader with empty input returns default false", func(t *testing.T) {
+ // Redirect stdin
+ oldStdin := os.Stdin
+ reader, writer, _ := os.Pipe()
+ os.Stdin = reader
+
+ // Write just newline and close writer
+ writer.WriteString("\n")
+ writer.Close()
+
+ // Test with default false
+ result := CLIYesOrNoReader("Test question", false)
+ os.Stdin = oldStdin
+ assert.False(t, result)
+ })
+
+ t.Run("CLIYesOrNoReader with other input returns false", func(t *testing.T) {
+ // Redirect stdin
+ oldStdin := os.Stdin
+ reader, writer, _ := os.Pipe()
+ os.Stdin = reader
+
+ // Write other input and close writer
+ writer.WriteString("maybe\n")
+ writer.Close()
+
+ result := CLIYesOrNoReader("Test question", true)
+
+ // Restore stdin
+ os.Stdin = oldStdin
+
+ assert.False(t, result)
+ })
+
+ t.Run("CLIYesOrNoReader message format with default true", func(t *testing.T) {
+ // Capture stdout to verify message format
+ oldStdout := os.Stdout
+ stdoutReader, stdoutWriter, _ := os.Pipe()
+ os.Stdout = stdoutWriter
+
+ // Redirect stdin
+ oldStdin := os.Stdin
+ stdinReader, stdinWriter, _ := os.Pipe()
+ os.Stdin = stdinReader
+
+ // Write input and close writer
+ stdinWriter.WriteString("y\n")
+ stdinWriter.Close()
+
+ CLIYesOrNoReader("Continue?", true)
+
+ // Restore stdin and stdout
+ os.Stdin = oldStdin
+ stdoutWriter.Close()
+ os.Stdout = oldStdout
+
+ // Read output
+ var buf bytes.Buffer
+ io.Copy(&buf, stdoutReader)
+ output := buf.String()
+
+ assert.Contains(t, output, "Continue? [Y/n]:")
+ })
+
+ t.Run("CLIYesOrNoReader message format with default false", func(t *testing.T) {
+ // Capture stdout to verify message format
+ oldStdout := os.Stdout
+ stdoutReader, stdoutWriter, _ := os.Pipe()
+ os.Stdout = stdoutWriter
+
+ // Redirect stdin
+ oldStdin := os.Stdin
+ stdinReader, stdinWriter, _ := os.Pipe()
+ os.Stdin = stdinReader
+
+ // Write input and close writer
+ stdinWriter.WriteString("n\n")
+ stdinWriter.Close()
+
+ CLIYesOrNoReader("Delete files?", false)
+
+ // Restore stdin and stdout
+ os.Stdin = oldStdin
+ stdoutWriter.Close()
+ os.Stdout = oldStdout
+
+ // Read output
+ var buf bytes.Buffer
+ io.Copy(&buf, stdoutReader)
+ output := buf.String()
+
+ assert.Contains(t, output, "Delete files? [y/N]:")
+ })
+}
+
+func TestConfigCLIParser_EdgeCases(t *testing.T) {
+ t.Run("Parse with zero duration", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-o", "0s"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.Duration)
+ assert.Equal(t, time.Duration(0), *config.Duration)
+ })
+
+ t.Run("Parse with zero timeout", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-t", "0s"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.Timeout)
+ assert.Equal(t, time.Duration(0), *config.Timeout)
+ })
+
+ t.Run("Parse with zero dodos count", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-d", "0"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.DodosCount)
+ assert.Equal(t, uint(0), *config.DodosCount)
+ })
+
+ t.Run("Parse with zero request count", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{"dodo", "-r", "0"})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.RequestCount)
+ assert.Equal(t, uint(0), *config.RequestCount)
+ })
+
+ t.Run("Parse with empty string values", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{
+ "dodo",
+ "-m", "",
+ "-p", "",
+ "-H", "",
+ "-c", "",
+ "-b", "",
+ })
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ assert.Empty(t, *config.Method)
+ assert.Len(t, config.Params, 1)
+ assert.Empty(t, config.Params[0].Key)
+ assert.Len(t, config.Headers, 1)
+ assert.Empty(t, config.Headers[0].Key)
+ assert.Len(t, config.Cookies, 1)
+ assert.Empty(t, config.Cookies[0].Key)
+ assert.Len(t, config.Bodies, 1)
+ assert.Equal(t, types.Body(""), config.Bodies[0])
+ })
+
+ t.Run("Parse with complex URL", func(t *testing.T) {
+ complexURL := "https://user:pass@api.example.com:8080/v1/endpoint?param=value&other=test#fragment"
+ parser := NewConfigCLIParser([]string{"dodo", "-u", complexURL})
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+ require.NotNil(t, config.URL)
+
+ parsedURL, parseErr := url.Parse(complexURL)
+ require.NoError(t, parseErr)
+ assert.Equal(t, parsedURL, config.URL)
+ })
+
+ t.Run("Parse with repeated same flags overrides previous values", func(t *testing.T) {
+ parser := NewConfigCLIParser([]string{
+ "dodo",
+ "-m", "GET",
+ "-m", "POST", // This should override the previous
+ "-d", "1",
+ "-d", "5", // This should override the previous
+ })
+ config, err := parser.Parse()
+
+ require.NoError(t, err)
+ require.NotNil(t, config)
+
+ assert.Equal(t, "POST", *config.Method)
+ assert.Equal(t, uint(5), *config.DodosCount)
+ })
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
new file mode 100644
index 0000000..86f2b18
--- /dev/null
+++ b/pkg/config/config.go
@@ -0,0 +1,112 @@
+package config
+
+import (
+ "net/url"
+ "time"
+
+ "github.com/aykhans/dodo/pkg/types"
+ "github.com/aykhans/dodo/pkg/utils"
+)
+
+const VERSION string = "1.0.0"
+
+var Defaults = struct {
+ UserAgent string
+ Method string
+ RequestTimeout time.Duration
+ DodosCount uint
+ Yes bool
+ SkipVerify bool
+}{
+ UserAgent: "dodo/" + VERSION,
+ Method: "GET",
+ RequestTimeout: time.Second * 10,
+ DodosCount: 1,
+ Yes: false,
+ SkipVerify: false,
+}
+
+var SupportedProxySchemes []string = []string{"http", "socks5", "socks5h"}
+
+type Config struct {
+ Method *string
+ URL *url.URL
+ Timeout *time.Duration
+ DodosCount *uint
+ RequestCount *uint
+ Duration *time.Duration
+ Yes *bool
+ SkipVerify *bool
+ Params types.Params
+ Headers types.Headers
+ Cookies types.Cookies
+ Bodies types.Bodies
+ Proxies types.Proxies
+}
+
+func NewConfig() *Config {
+ return &Config{}
+}
+
+func (config *Config) MergeConfig(newConfig *Config) {
+ if newConfig.Method != nil {
+ config.Method = newConfig.Method
+ }
+ if newConfig.URL != nil {
+ config.URL = newConfig.URL
+ }
+ if newConfig.Timeout != nil {
+ config.Timeout = newConfig.Timeout
+ }
+ if newConfig.DodosCount != nil {
+ config.DodosCount = newConfig.DodosCount
+ }
+ if newConfig.RequestCount != nil {
+ config.RequestCount = newConfig.RequestCount
+ }
+ if newConfig.Duration != nil {
+ config.Duration = newConfig.Duration
+ }
+ if newConfig.Yes != nil {
+ config.Yes = newConfig.Yes
+ }
+ if newConfig.SkipVerify != nil {
+ config.SkipVerify = newConfig.SkipVerify
+ }
+ if len(newConfig.Params) != 0 {
+ config.Params = newConfig.Params
+ }
+ if len(newConfig.Headers) != 0 {
+ config.Headers = newConfig.Headers
+ }
+ if len(newConfig.Cookies) != 0 {
+ config.Cookies = newConfig.Cookies
+ }
+ if len(newConfig.Bodies) != 0 {
+ config.Bodies = newConfig.Bodies
+ }
+ if len(newConfig.Proxies) != 0 {
+ config.Proxies = newConfig.Proxies
+ }
+}
+
+func (config *Config) SetDefaults() {
+ if config.Method == nil {
+ config.Method = utils.ToPtr(Defaults.Method)
+ }
+ if config.Timeout == nil {
+ config.Timeout = &Defaults.RequestTimeout
+ }
+ if config.DodosCount == nil {
+ config.DodosCount = utils.ToPtr(Defaults.DodosCount)
+ }
+ if config.Yes == nil {
+ config.Yes = utils.ToPtr(Defaults.Yes)
+ }
+ if config.SkipVerify == nil {
+ config.SkipVerify = utils.ToPtr(Defaults.SkipVerify)
+ }
+ if !config.Headers.Has("User-Agent") {
+ config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
+ }
+}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
new file mode 100644
index 0000000..e7c4f20
--- /dev/null
+++ b/pkg/config/config_test.go
@@ -0,0 +1,318 @@
+package config
+
+import (
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/aykhans/dodo/pkg/types"
+ "github.com/aykhans/dodo/pkg/utils"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMergeConfig(t *testing.T) {
+ t.Run("MergeConfig with all fields from new config", func(t *testing.T) {
+ originalURL, _ := url.Parse("https://original.example.com")
+ newURL, _ := url.Parse("https://new.example.com")
+
+ originalTimeout := 5 * time.Second
+ newTimeout := 10 * time.Second
+
+ originalDuration := 1 * time.Minute
+ newDuration := 2 * time.Minute
+
+ config := &Config{
+ Method: utils.ToPtr("GET"),
+ URL: originalURL,
+ Timeout: &originalTimeout,
+ DodosCount: utils.ToPtr(uint(1)),
+ RequestCount: utils.ToPtr(uint(10)),
+ Duration: &originalDuration,
+ Yes: utils.ToPtr(false),
+ SkipVerify: utils.ToPtr(false),
+ Params: types.Params{{Key: "old", Value: []string{"value"}}},
+ Headers: types.Headers{{Key: "Old-Header", Value: []string{"old"}}},
+ Cookies: types.Cookies{{Key: "oldCookie", Value: []string{"oldValue"}}},
+ Bodies: types.Bodies{types.Body("old body")},
+ Proxies: types.Proxies{},
+ }
+
+ newConfig := &Config{
+ Method: utils.ToPtr("POST"),
+ URL: newURL,
+ Timeout: &newTimeout,
+ DodosCount: utils.ToPtr(uint(5)),
+ RequestCount: utils.ToPtr(uint(20)),
+ Duration: &newDuration,
+ Yes: utils.ToPtr(true),
+ SkipVerify: utils.ToPtr(true),
+ Params: types.Params{{Key: "new", Value: []string{"value"}}},
+ Headers: types.Headers{{Key: "New-Header", Value: []string{"new"}}},
+ Cookies: types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}},
+ Bodies: types.Bodies{types.Body("new body")},
+ Proxies: types.Proxies{},
+ }
+
+ config.MergeConfig(newConfig)
+
+ assert.Equal(t, "POST", *config.Method)
+ assert.Equal(t, newURL, config.URL)
+ assert.Equal(t, newTimeout, *config.Timeout)
+ assert.Equal(t, uint(5), *config.DodosCount)
+ assert.Equal(t, uint(20), *config.RequestCount)
+ assert.Equal(t, newDuration, *config.Duration)
+ assert.True(t, *config.Yes)
+ assert.True(t, *config.SkipVerify)
+ assert.Equal(t, types.Params{{Key: "new", Value: []string{"value"}}}, config.Params)
+ assert.Equal(t, types.Headers{{Key: "New-Header", Value: []string{"new"}}}, config.Headers)
+ assert.Equal(t, types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, config.Cookies)
+ assert.Equal(t, types.Bodies{types.Body("new body")}, config.Bodies)
+ assert.Empty(t, config.Proxies)
+ })
+
+ t.Run("MergeConfig with partial fields from new config", func(t *testing.T) {
+ originalURL, _ := url.Parse("https://original.example.com")
+ originalTimeout := 5 * time.Second
+
+ config := &Config{
+ Method: utils.ToPtr("GET"),
+ URL: originalURL,
+ Timeout: &originalTimeout,
+ DodosCount: utils.ToPtr(uint(1)),
+ Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
+ }
+
+ newURL, _ := url.Parse("https://new.example.com")
+ newConfig := &Config{
+ URL: newURL,
+ DodosCount: utils.ToPtr(uint(10)),
+ }
+
+ config.MergeConfig(newConfig)
+
+ assert.Equal(t, "GET", *config.Method, "Method should remain unchanged")
+ assert.Equal(t, newURL, config.URL, "URL should be updated")
+ assert.Equal(t, originalTimeout, *config.Timeout, "Timeout should remain unchanged")
+ assert.Equal(t, uint(10), *config.DodosCount, "DodosCount should be updated")
+ assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Headers should remain unchanged")
+ })
+
+ t.Run("MergeConfig with nil new config fields", func(t *testing.T) {
+ originalURL, _ := url.Parse("https://original.example.com")
+ originalTimeout := 5 * time.Second
+
+ config := &Config{
+ Method: utils.ToPtr("GET"),
+ URL: originalURL,
+ Timeout: &originalTimeout,
+ DodosCount: utils.ToPtr(uint(1)),
+ Yes: utils.ToPtr(false),
+ SkipVerify: utils.ToPtr(false),
+ }
+
+ newConfig := &Config{
+ Method: nil,
+ URL: nil,
+ Timeout: nil,
+ DodosCount: nil,
+ Yes: nil,
+ SkipVerify: nil,
+ }
+
+ originalConfigCopy := *config
+ config.MergeConfig(newConfig)
+
+ assert.Equal(t, originalConfigCopy.Method, config.Method)
+ assert.Equal(t, originalConfigCopy.URL, config.URL)
+ assert.Equal(t, originalConfigCopy.Timeout, config.Timeout)
+ assert.Equal(t, originalConfigCopy.DodosCount, config.DodosCount)
+ assert.Equal(t, originalConfigCopy.Yes, config.Yes)
+ assert.Equal(t, originalConfigCopy.SkipVerify, config.SkipVerify)
+ })
+
+ t.Run("MergeConfig with empty slices", func(t *testing.T) {
+ config := &Config{
+ Params: types.Params{{Key: "original", Value: []string{"value"}}},
+ Headers: types.Headers{{Key: "Original-Header", Value: []string{"original"}}},
+ Cookies: types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}},
+ Bodies: types.Bodies{types.Body("original body")},
+ Proxies: types.Proxies{},
+ }
+
+ newConfig := &Config{
+ Params: types.Params{},
+ Headers: types.Headers{},
+ Cookies: types.Cookies{},
+ Bodies: types.Bodies{},
+ Proxies: types.Proxies{},
+ }
+
+ config.MergeConfig(newConfig)
+
+ assert.Equal(t, types.Params{{Key: "original", Value: []string{"value"}}}, config.Params, "Empty Params should not override")
+ assert.Equal(t, types.Headers{{Key: "Original-Header", Value: []string{"original"}}}, config.Headers, "Empty Headers should not override")
+ assert.Equal(t, types.Cookies{{Key: "originalCookie", Value: []string{"originalValue"}}}, config.Cookies, "Empty Cookies should not override")
+ assert.Equal(t, types.Bodies{types.Body("original body")}, config.Bodies, "Empty Bodies should not override")
+ assert.Equal(t, types.Proxies{}, config.Proxies, "Empty Proxies should not override")
+ })
+
+ t.Run("MergeConfig on empty original config", func(t *testing.T) {
+ config := &Config{}
+
+ newURL, _ := url.Parse("https://new.example.com")
+ newTimeout := 10 * time.Second
+ newDuration := 2 * time.Minute
+
+ newConfig := &Config{
+ Method: utils.ToPtr("POST"),
+ URL: newURL,
+ Timeout: &newTimeout,
+ DodosCount: utils.ToPtr(uint(5)),
+ RequestCount: utils.ToPtr(uint(20)),
+ Duration: &newDuration,
+ Yes: utils.ToPtr(true),
+ SkipVerify: utils.ToPtr(true),
+ Params: types.Params{{Key: "new", Value: []string{"value"}}},
+ Headers: types.Headers{{Key: "New-Header", Value: []string{"new"}}},
+ Cookies: types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}},
+ Bodies: types.Bodies{types.Body("new body")},
+ Proxies: types.Proxies{},
+ }
+
+ config.MergeConfig(newConfig)
+
+ assert.Equal(t, "POST", *config.Method)
+ assert.Equal(t, newURL, config.URL)
+ assert.Equal(t, newTimeout, *config.Timeout)
+ assert.Equal(t, uint(5), *config.DodosCount)
+ assert.Equal(t, uint(20), *config.RequestCount)
+ assert.Equal(t, newDuration, *config.Duration)
+ assert.True(t, *config.Yes)
+ assert.True(t, *config.SkipVerify)
+ assert.Equal(t, types.Params{{Key: "new", Value: []string{"value"}}}, config.Params)
+ assert.Equal(t, types.Headers{{Key: "New-Header", Value: []string{"new"}}}, config.Headers)
+ assert.Equal(t, types.Cookies{{Key: "newCookie", Value: []string{"newValue"}}}, config.Cookies)
+ assert.Equal(t, types.Bodies{types.Body("new body")}, config.Bodies)
+ assert.Empty(t, config.Proxies)
+ })
+}
+
+func TestSetDefaults(t *testing.T) {
+ t.Run("SetDefaults on empty config", func(t *testing.T) {
+ config := &Config{}
+ config.SetDefaults()
+
+ require.NotNil(t, config.Method)
+ assert.Equal(t, Defaults.Method, *config.Method)
+
+ require.NotNil(t, config.Timeout)
+ assert.Equal(t, Defaults.RequestTimeout, *config.Timeout)
+
+ require.NotNil(t, config.DodosCount)
+ assert.Equal(t, Defaults.DodosCount, *config.DodosCount)
+
+ require.NotNil(t, config.Yes)
+ assert.Equal(t, Defaults.Yes, *config.Yes)
+
+ require.NotNil(t, config.SkipVerify)
+ assert.Equal(t, Defaults.SkipVerify, *config.SkipVerify)
+
+ assert.True(t, config.Headers.Has("User-Agent"))
+ assert.Equal(t, Defaults.UserAgent, config.Headers[0].Value[0])
+ })
+
+ t.Run("SetDefaults preserves existing values", func(t *testing.T) {
+ customTimeout := 30 * time.Second
+ config := &Config{
+ Method: utils.ToPtr("POST"),
+ Timeout: &customTimeout,
+ DodosCount: utils.ToPtr(uint(10)),
+ Yes: utils.ToPtr(true),
+ SkipVerify: utils.ToPtr(true),
+ Headers: types.Headers{{Key: "User-Agent", Value: []string{"custom-agent"}}},
+ }
+
+ config.SetDefaults()
+
+ assert.Equal(t, "POST", *config.Method, "Method should not be overridden")
+ assert.Equal(t, customTimeout, *config.Timeout, "Timeout should not be overridden")
+ assert.Equal(t, uint(10), *config.DodosCount, "DodosCount should not be overridden")
+ assert.True(t, *config.Yes, "Yes should not be overridden")
+ assert.True(t, *config.SkipVerify, "SkipVerify should not be overridden")
+ assert.Equal(t, "custom-agent", config.Headers[0].Value[0], "User-Agent should not be overridden")
+ assert.Len(t, config.Headers, 1, "Should not add duplicate User-Agent")
+ })
+
+ t.Run("SetDefaults adds User-Agent when missing", func(t *testing.T) {
+ config := &Config{
+ Headers: types.Headers{{Key: "Content-Type", Value: []string{"application/json"}}},
+ }
+
+ config.SetDefaults()
+
+ assert.Len(t, config.Headers, 2)
+ assert.True(t, config.Headers.Has("User-Agent"))
+ assert.True(t, config.Headers.Has("Content-Type"))
+
+ var userAgentFound bool
+ for _, h := range config.Headers {
+ if h.Key == "User-Agent" {
+ userAgentFound = true
+ assert.Equal(t, Defaults.UserAgent, h.Value[0])
+ break
+ }
+ }
+ assert.True(t, userAgentFound, "User-Agent header should be added")
+ })
+
+ t.Run("SetDefaults with partial config", func(t *testing.T) {
+ config := &Config{
+ Method: utils.ToPtr("PUT"),
+ Yes: utils.ToPtr(true),
+ }
+
+ config.SetDefaults()
+
+ assert.Equal(t, "PUT", *config.Method, "Existing Method should be preserved")
+ assert.True(t, *config.Yes, "Existing Yes should be preserved")
+
+ require.NotNil(t, config.Timeout)
+ assert.Equal(t, Defaults.RequestTimeout, *config.Timeout, "Timeout should be set to default")
+
+ require.NotNil(t, config.DodosCount)
+ assert.Equal(t, Defaults.DodosCount, *config.DodosCount, "DodosCount should be set to default")
+
+ require.NotNil(t, config.SkipVerify)
+ assert.Equal(t, Defaults.SkipVerify, *config.SkipVerify, "SkipVerify should be set to default")
+
+ assert.True(t, config.Headers.Has("User-Agent"))
+ })
+
+ t.Run("SetDefaults idempotent", func(t *testing.T) {
+ config := &Config{}
+
+ config.SetDefaults()
+ firstCallHeaders := len(config.Headers)
+ firstCallMethod := *config.Method
+ firstCallTimeout := *config.Timeout
+
+ config.SetDefaults()
+
+ assert.Len(t, config.Headers, firstCallHeaders, "Headers count should not change on second call")
+ assert.Equal(t, firstCallMethod, *config.Method, "Method should not change on second call")
+ assert.Equal(t, firstCallTimeout, *config.Timeout, "Timeout should not change on second call")
+ })
+
+ t.Run("SetDefaults with empty Headers initializes correctly", func(t *testing.T) {
+ config := &Config{
+ Headers: types.Headers{},
+ }
+
+ config.SetDefaults()
+
+ assert.Len(t, config.Headers, 1)
+ assert.Equal(t, "User-Agent", config.Headers[0].Key)
+ assert.Equal(t, Defaults.UserAgent, config.Headers[0].Value[0])
+ })
+}
diff --git a/pkg/types/body.go b/pkg/types/body.go
new file mode 100644
index 0000000..7e4ae08
--- /dev/null
+++ b/pkg/types/body.go
@@ -0,0 +1,23 @@
+package types
+
+type Body string
+
+func (body Body) String() string {
+ return string(body)
+}
+
+type Bodies []Body
+
+func (bodies *Bodies) Append(body Body) {
+ *bodies = append(*bodies, body)
+}
+
+func (bodies *Bodies) Parse(rawValues ...string) {
+ for _, rawValue := range rawValues {
+ bodies.Append(ParseBody(rawValue))
+ }
+}
+
+func ParseBody(rawValue string) Body {
+ return Body(rawValue)
+}
diff --git a/pkg/types/body_test.go b/pkg/types/body_test.go
new file mode 100644
index 0000000..deb7a06
--- /dev/null
+++ b/pkg/types/body_test.go
@@ -0,0 +1,160 @@
+package types
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestBody_String(t *testing.T) {
+ t.Run("Body String returns correct value", func(t *testing.T) {
+ body := Body("test body content")
+ assert.Equal(t, "test body content", body.String())
+ })
+
+ t.Run("Body String with empty body", func(t *testing.T) {
+ body := Body("")
+ assert.Empty(t, body.String())
+ })
+
+ t.Run("Body String with JSON", func(t *testing.T) {
+ body := Body(`{"key": "value", "number": 42}`)
+ assert.JSONEq(t, `{"key": "value", "number": 42}`, body.String())
+ })
+
+ t.Run("Body String with special characters", func(t *testing.T) {
+ body := Body("special: !@#$%^&*()\nnewline\ttab")
+ assert.Equal(t, "special: !@#$%^&*()\nnewline\ttab", body.String())
+ })
+}
+
+func TestBodies_Append(t *testing.T) {
+ t.Run("Append single body", func(t *testing.T) {
+ bodies := &Bodies{}
+ bodies.Append(Body("first body"))
+
+ assert.Len(t, *bodies, 1)
+ assert.Equal(t, Body("first body"), (*bodies)[0])
+ })
+
+ t.Run("Append multiple bodies", func(t *testing.T) {
+ bodies := &Bodies{}
+ bodies.Append(Body("first"))
+ bodies.Append(Body("second"))
+ bodies.Append(Body("third"))
+
+ assert.Len(t, *bodies, 3)
+ assert.Equal(t, Body("first"), (*bodies)[0])
+ assert.Equal(t, Body("second"), (*bodies)[1])
+ assert.Equal(t, Body("third"), (*bodies)[2])
+ })
+
+ t.Run("Append to existing bodies", func(t *testing.T) {
+ bodies := &Bodies{Body("existing")}
+ bodies.Append(Body("new"))
+
+ assert.Len(t, *bodies, 2)
+ assert.Equal(t, Body("existing"), (*bodies)[0])
+ assert.Equal(t, Body("new"), (*bodies)[1])
+ })
+
+ t.Run("Append empty body", func(t *testing.T) {
+ bodies := &Bodies{}
+ bodies.Append(Body(""))
+
+ assert.Len(t, *bodies, 1)
+ assert.Empty(t, (*bodies)[0].String())
+ })
+}
+
+func TestBodies_Parse(t *testing.T) {
+ t.Run("Parse single value", func(t *testing.T) {
+ bodies := &Bodies{}
+ bodies.Parse("test body")
+
+ assert.Len(t, *bodies, 1)
+ assert.Equal(t, Body("test body"), (*bodies)[0])
+ })
+
+ t.Run("Parse multiple values", func(t *testing.T) {
+ bodies := &Bodies{}
+ bodies.Parse("body1", "body2", "body3")
+
+ assert.Len(t, *bodies, 3)
+ assert.Equal(t, Body("body1"), (*bodies)[0])
+ assert.Equal(t, Body("body2"), (*bodies)[1])
+ assert.Equal(t, Body("body3"), (*bodies)[2])
+ })
+
+ t.Run("Parse with existing bodies", func(t *testing.T) {
+ bodies := &Bodies{Body("existing")}
+ bodies.Parse("new1", "new2")
+
+ assert.Len(t, *bodies, 3)
+ assert.Equal(t, Body("existing"), (*bodies)[0])
+ assert.Equal(t, Body("new1"), (*bodies)[1])
+ assert.Equal(t, Body("new2"), (*bodies)[2])
+ })
+
+ t.Run("Parse empty values", func(t *testing.T) {
+ bodies := &Bodies{}
+ bodies.Parse("", "", "")
+
+ assert.Len(t, *bodies, 3)
+ for _, body := range *bodies {
+ assert.Empty(t, body.String())
+ }
+ })
+
+ t.Run("Parse no arguments", func(t *testing.T) {
+ bodies := &Bodies{}
+ bodies.Parse()
+
+ assert.Empty(t, *bodies)
+ })
+
+ t.Run("Parse JSON strings", func(t *testing.T) {
+ bodies := &Bodies{}
+ bodies.Parse(`{"key": "value"}`, `{"array": [1, 2, 3]}`)
+
+ assert.Len(t, *bodies, 2)
+ assert.JSONEq(t, `{"key": "value"}`, (*bodies)[0].String())
+ assert.JSONEq(t, `{"array": [1, 2, 3]}`, (*bodies)[1].String())
+ })
+}
+
+func TestParseBody(t *testing.T) {
+ t.Run("ParseBody with regular string", func(t *testing.T) {
+ body := ParseBody("test content")
+ assert.Equal(t, Body("test content"), body)
+ })
+
+ t.Run("ParseBody with empty string", func(t *testing.T) {
+ body := ParseBody("")
+ assert.Equal(t, Body(""), body)
+ })
+
+ t.Run("ParseBody with multiline string", func(t *testing.T) {
+ input := "line1\nline2\nline3"
+ body := ParseBody(input)
+ assert.Equal(t, Body(input), body)
+ })
+
+ t.Run("ParseBody with special characters", func(t *testing.T) {
+ input := "!@#$%^&*()_+-=[]{}|;':\",./<>?"
+ body := ParseBody(input)
+ assert.Equal(t, Body(input), body)
+ })
+
+ t.Run("ParseBody with unicode", func(t *testing.T) {
+ input := "Hello World 🌍"
+ body := ParseBody(input)
+ assert.Equal(t, Body(input), body)
+ })
+
+ t.Run("ParseBody preserves whitespace", func(t *testing.T) {
+ input := " leading and trailing spaces "
+ body := ParseBody(input)
+ assert.Equal(t, Body(input), body)
+ })
+}
diff --git a/pkg/types/config_file.go b/pkg/types/config_file.go
new file mode 100644
index 0000000..d4026a4
--- /dev/null
+++ b/pkg/types/config_file.go
@@ -0,0 +1,92 @@
+package types
+
+import (
+ "net/url"
+ "path/filepath"
+ "strings"
+)
+
+type ConfigFileType int
+
+const (
+ ConfigFileTypeYAML ConfigFileType = iota
+)
+
+func (t ConfigFileType) String() string {
+ switch t {
+ case ConfigFileTypeYAML:
+ return "yaml"
+ default:
+ return "unknown"
+ }
+}
+
+type ConfigFileLocationType int
+
+const (
+ ConfigFileLocationLocal ConfigFileLocationType = iota
+ ConfigFileLocationRemote
+)
+
+func (l ConfigFileLocationType) String() string {
+ switch l {
+ case ConfigFileLocationLocal:
+ return "local"
+ case ConfigFileLocationRemote:
+ return "remote"
+ default:
+ return "unknown"
+ }
+}
+
+type ConfigFile struct {
+ path string
+ _type ConfigFileType
+ locationType ConfigFileLocationType
+}
+
+func (configFile ConfigFile) String() string {
+ return configFile.path
+}
+
+func (configFile ConfigFile) Type() ConfigFileType {
+ return configFile._type
+}
+
+func (configFile ConfigFile) LocationType() ConfigFileLocationType {
+ return configFile.locationType
+}
+
+func ParseConfigFile(configFileRaw string) (*ConfigFile, error) {
+ configFileParsed := &ConfigFile{
+ path: configFileRaw,
+ locationType: ConfigFileLocationLocal,
+ }
+
+ if strings.HasPrefix(configFileRaw, "http://") || strings.HasPrefix(configFileRaw, "https://") {
+ configFileParsed.locationType = ConfigFileLocationRemote
+ }
+
+ configFilePath := configFileRaw
+ if configFileParsed.locationType == ConfigFileLocationRemote {
+ remoteConfigFileParsed, err := url.Parse(configFileRaw)
+ if err != nil {
+ return nil, NewRemoteConfigFileParseError(err)
+ }
+ configFilePath = remoteConfigFileParsed.Path
+ }
+
+ configFileExtension, _ := strings.CutPrefix(filepath.Ext(configFilePath), ".")
+ if configFileExtension == "" {
+ return nil, ErrConfigFileExtensionNotFound
+ }
+
+ switch strings.ToLower(configFileExtension) {
+ case "yml", "yaml":
+ configFileParsed._type = ConfigFileTypeYAML
+ default:
+ return nil, NewUnknownConfigFileTypeError(configFileExtension)
+ }
+
+ return configFileParsed, nil
+}
diff --git a/pkg/types/config_file_test.go b/pkg/types/config_file_test.go
new file mode 100644
index 0000000..fe5f1c7
--- /dev/null
+++ b/pkg/types/config_file_test.go
@@ -0,0 +1,216 @@
+package types
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConfigFileType_String(t *testing.T) {
+ t.Run("ConfigFileTypeYAML returns yaml", func(t *testing.T) {
+ configType := ConfigFileTypeYAML
+ assert.Equal(t, "yaml", configType.String())
+ })
+
+ t.Run("Unknown config file type returns unknown", func(t *testing.T) {
+ configType := ConfigFileType(999)
+ assert.Equal(t, "unknown", configType.String())
+ })
+}
+
+func TestConfigFileLocationType_String(t *testing.T) {
+ t.Run("ConfigFileLocationLocal returns local", func(t *testing.T) {
+ locationType := ConfigFileLocationLocal
+ assert.Equal(t, "local", locationType.String())
+ })
+
+ t.Run("ConfigFileLocationRemote returns remote", func(t *testing.T) {
+ locationType := ConfigFileLocationRemote
+ assert.Equal(t, "remote", locationType.String())
+ })
+
+ t.Run("Unknown location type returns unknown", func(t *testing.T) {
+ locationType := ConfigFileLocationType(999)
+ assert.Equal(t, "unknown", locationType.String())
+ })
+}
+
+func TestConfigFile_String(t *testing.T) {
+ t.Run("String returns the file path", func(t *testing.T) {
+ configFile := ConfigFile{path: "/path/to/config.yaml"}
+ assert.Equal(t, "/path/to/config.yaml", configFile.String())
+ })
+
+ t.Run("String returns empty path", func(t *testing.T) {
+ configFile := ConfigFile{path: ""}
+ assert.Empty(t, configFile.String())
+ })
+}
+
+func TestConfigFile_Type(t *testing.T) {
+ t.Run("Type returns the config file type", func(t *testing.T) {
+ configFile := ConfigFile{_type: ConfigFileTypeYAML}
+ assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
+ })
+}
+
+func TestConfigFile_LocationType(t *testing.T) {
+ t.Run("LocationType returns local", func(t *testing.T) {
+ configFile := ConfigFile{locationType: ConfigFileLocationLocal}
+ assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
+ })
+
+ t.Run("LocationType returns remote", func(t *testing.T) {
+ configFile := ConfigFile{locationType: ConfigFileLocationRemote}
+ assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
+ })
+}
+
+func TestParseConfigFile(t *testing.T) {
+ t.Run("Parse local YAML file with yml extension", func(t *testing.T) {
+ configFile, err := ParseConfigFile("config.yml")
+
+ require.NoError(t, err)
+ require.NotNil(t, configFile)
+ assert.Equal(t, "config.yml", configFile.String())
+ assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
+ assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
+ })
+
+ t.Run("Parse local YAML file with yaml extension", func(t *testing.T) {
+ configFile, err := ParseConfigFile("config.yaml")
+
+ require.NoError(t, err)
+ require.NotNil(t, configFile)
+ assert.Equal(t, "config.yaml", configFile.String())
+ assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
+ assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
+ })
+
+ t.Run("Parse local YAML file with uppercase extensions", func(t *testing.T) {
+ testCases := []string{"config.YML", "config.YAML", "config.Yml", "config.Yaml"}
+
+ for _, testCase := range testCases {
+ t.Run("Extension: "+testCase, func(t *testing.T) {
+ configFile, err := ParseConfigFile(testCase)
+
+ require.NoError(t, err)
+ require.NotNil(t, configFile)
+ assert.Equal(t, testCase, configFile.String())
+ assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
+ assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
+ })
+ }
+ })
+
+ t.Run("Parse remote HTTP YAML file", func(t *testing.T) {
+ configFile, err := ParseConfigFile("http://example.com/config.yaml")
+
+ require.NoError(t, err)
+ require.NotNil(t, configFile)
+ assert.Equal(t, "http://example.com/config.yaml", configFile.String())
+ assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
+ assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
+ })
+
+ t.Run("Parse remote HTTPS YAML file", func(t *testing.T) {
+ configFile, err := ParseConfigFile("https://example.com/path/config.yml")
+
+ require.NoError(t, err)
+ require.NotNil(t, configFile)
+ assert.Equal(t, "https://example.com/path/config.yml", configFile.String())
+ assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
+ assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
+ })
+
+ t.Run("Parse file with path separators", func(t *testing.T) {
+ configFile, err := ParseConfigFile("/path/to/config.yaml")
+
+ require.NoError(t, err)
+ require.NotNil(t, configFile)
+ assert.Equal(t, "/path/to/config.yaml", configFile.String())
+ assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
+ assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
+ })
+
+ t.Run("Parse file without extension returns error", func(t *testing.T) {
+ configFile, err := ParseConfigFile("config")
+
+ require.Error(t, err)
+ assert.Equal(t, ErrConfigFileExtensionNotFound, err)
+ assert.Nil(t, configFile)
+ })
+
+ t.Run("Parse file with unsupported extension returns error", func(t *testing.T) {
+ configFile, err := ParseConfigFile("config.json")
+
+ require.Error(t, err)
+ assert.IsType(t, &UnknownConfigFileTypeError{}, err)
+ assert.Contains(t, err.Error(), "json")
+ assert.Nil(t, configFile)
+ })
+
+ t.Run("Parse remote file with invalid URL returns error", func(t *testing.T) {
+ configFile, err := ParseConfigFile("http://192.168.1.%30/config.yaml")
+
+ require.Error(t, err)
+ assert.IsType(t, &RemoteConfigFileParseError{}, err)
+ assert.Nil(t, configFile)
+ })
+
+ t.Run("Parse remote file without extension returns error", func(t *testing.T) {
+ configFile, err := ParseConfigFile("https://example.com/config")
+
+ require.Error(t, err)
+ assert.Equal(t, ErrConfigFileExtensionNotFound, err)
+ assert.Nil(t, configFile)
+ })
+
+ t.Run("Parse remote file with unsupported extension returns error", func(t *testing.T) {
+ configFile, err := ParseConfigFile("https://example.com/config.txt")
+
+ require.Error(t, err)
+ assert.IsType(t, &UnknownConfigFileTypeError{}, err)
+ assert.Contains(t, err.Error(), "txt")
+ assert.Nil(t, configFile)
+ })
+
+ t.Run("Parse empty string returns error", func(t *testing.T) {
+ configFile, err := ParseConfigFile("")
+
+ require.Error(t, err)
+ assert.Equal(t, ErrConfigFileExtensionNotFound, err)
+ assert.Nil(t, configFile)
+ })
+
+ t.Run("Parse file with multiple dots in name", func(t *testing.T) {
+ configFile, err := ParseConfigFile("config.test.yaml")
+
+ require.NoError(t, err)
+ require.NotNil(t, configFile)
+ assert.Equal(t, "config.test.yaml", configFile.String())
+ assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
+ assert.Equal(t, ConfigFileLocationLocal, configFile.LocationType())
+ })
+
+ t.Run("Parse remote URL with query parameters and fragment", func(t *testing.T) {
+ configFile, err := ParseConfigFile("https://example.com/path/config.yaml?version=1&format=yaml#section")
+
+ require.NoError(t, err)
+ require.NotNil(t, configFile)
+ assert.Equal(t, "https://example.com/path/config.yaml?version=1&format=yaml#section", configFile.String())
+ assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
+ assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
+ })
+
+ t.Run("Parse remote URL with port", func(t *testing.T) {
+ configFile, err := ParseConfigFile("https://example.com:8080/config.yml")
+
+ require.NoError(t, err)
+ require.NotNil(t, configFile)
+ assert.Equal(t, "https://example.com:8080/config.yml", configFile.String())
+ assert.Equal(t, ConfigFileTypeYAML, configFile.Type())
+ assert.Equal(t, ConfigFileLocationRemote, configFile.LocationType())
+ })
+}
diff --git a/pkg/types/cookie.go b/pkg/types/cookie.go
new file mode 100644
index 0000000..c2d97a8
--- /dev/null
+++ b/pkg/types/cookie.go
@@ -0,0 +1,42 @@
+package types
+
+import "strings"
+
+type Cookie KeyValue[string, []string]
+
+type Cookies []Cookie
+
+func (cookies Cookies) GetValue(key string) *[]string {
+ for i := range cookies {
+ if cookies[i].Key == key {
+ return &cookies[i].Value
+ }
+ }
+ return nil
+}
+
+func (cookies *Cookies) Append(cookie Cookie) {
+ if item := cookies.GetValue(cookie.Key); item != nil {
+ *item = append(*item, cookie.Value...)
+ } else {
+ *cookies = append(*cookies, cookie)
+ }
+}
+
+func (cookies *Cookies) Parse(rawValues ...string) {
+ for _, rawValue := range rawValues {
+ cookies.Append(*ParseCookie(rawValue))
+ }
+}
+
+func ParseCookie(rawValue string) *Cookie {
+ parts := strings.SplitN(rawValue, "=", 2)
+ switch len(parts) {
+ case 1:
+ return &Cookie{Key: parts[0], Value: []string{""}}
+ case 2:
+ return &Cookie{Key: parts[0], Value: []string{parts[1]}}
+ default:
+ return &Cookie{Key: "", Value: []string{""}}
+ }
+}
diff --git a/pkg/types/cookie_test.go b/pkg/types/cookie_test.go
new file mode 100644
index 0000000..a457d95
--- /dev/null
+++ b/pkg/types/cookie_test.go
@@ -0,0 +1,240 @@
+package types
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCookies_GetValue(t *testing.T) {
+ t.Run("GetValue returns existing cookie value", func(t *testing.T) {
+ cookies := Cookies{
+ {Key: "session", Value: []string{"abc123"}},
+ {Key: "user", Value: []string{"john"}},
+ }
+
+ value := cookies.GetValue("session")
+ require.NotNil(t, value)
+ assert.Equal(t, []string{"abc123"}, *value)
+ })
+
+ t.Run("GetValue returns nil for non-existent cookie", func(t *testing.T) {
+ cookies := Cookies{
+ {Key: "session", Value: []string{"abc123"}},
+ }
+
+ value := cookies.GetValue("nonexistent")
+ assert.Nil(t, value)
+ })
+
+ t.Run("GetValue with empty cookies", func(t *testing.T) {
+ cookies := Cookies{}
+
+ value := cookies.GetValue("any")
+ assert.Nil(t, value)
+ })
+
+ t.Run("GetValue with multiple values", func(t *testing.T) {
+ cookies := Cookies{
+ {Key: "multi", Value: []string{"val1", "val2", "val3"}},
+ }
+
+ value := cookies.GetValue("multi")
+ require.NotNil(t, value)
+ assert.Equal(t, []string{"val1", "val2", "val3"}, *value)
+ })
+
+ t.Run("GetValue case sensitive", func(t *testing.T) {
+ cookies := Cookies{
+ {Key: "Cookie", Value: []string{"value"}},
+ }
+
+ value1 := cookies.GetValue("Cookie")
+ require.NotNil(t, value1)
+ assert.Equal(t, []string{"value"}, *value1)
+
+ value2 := cookies.GetValue("cookie")
+ assert.Nil(t, value2)
+ })
+}
+
+func TestCookies_Append(t *testing.T) {
+ t.Run("Append new cookie", func(t *testing.T) {
+ cookies := &Cookies{}
+ cookies.Append(Cookie{Key: "session", Value: []string{"abc123"}})
+
+ assert.Len(t, *cookies, 1)
+ assert.Equal(t, "session", (*cookies)[0].Key)
+ assert.Equal(t, []string{"abc123"}, (*cookies)[0].Value)
+ })
+
+ t.Run("Append to existing cookie key", func(t *testing.T) {
+ cookies := &Cookies{
+ {Key: "session", Value: []string{"abc123"}},
+ }
+ cookies.Append(Cookie{Key: "session", Value: []string{"def456"}})
+
+ assert.Len(t, *cookies, 1)
+ assert.Equal(t, []string{"abc123", "def456"}, (*cookies)[0].Value)
+ })
+
+ t.Run("Append different cookies", func(t *testing.T) {
+ cookies := &Cookies{}
+ cookies.Append(Cookie{Key: "session", Value: []string{"abc"}})
+ cookies.Append(Cookie{Key: "user", Value: []string{"john"}})
+ cookies.Append(Cookie{Key: "token", Value: []string{"xyz"}})
+
+ assert.Len(t, *cookies, 3)
+ })
+
+ t.Run("Append multiple values at once", func(t *testing.T) {
+ cookies := &Cookies{
+ {Key: "tags", Value: []string{"tag1"}},
+ }
+ cookies.Append(Cookie{Key: "tags", Value: []string{"tag2", "tag3"}})
+
+ assert.Len(t, *cookies, 1)
+ assert.Equal(t, []string{"tag1", "tag2", "tag3"}, (*cookies)[0].Value)
+ })
+
+ t.Run("Append empty value", func(t *testing.T) {
+ cookies := &Cookies{}
+ cookies.Append(Cookie{Key: "empty", Value: []string{""}})
+
+ assert.Len(t, *cookies, 1)
+ assert.Equal(t, []string{""}, (*cookies)[0].Value)
+ })
+}
+
+func TestCookies_Parse(t *testing.T) {
+ t.Run("Parse single cookie", func(t *testing.T) {
+ cookies := &Cookies{}
+ cookies.Parse("session=abc123")
+
+ assert.Len(t, *cookies, 1)
+ assert.Equal(t, "session", (*cookies)[0].Key)
+ assert.Equal(t, []string{"abc123"}, (*cookies)[0].Value)
+ })
+
+ t.Run("Parse multiple cookies", func(t *testing.T) {
+ cookies := &Cookies{}
+ cookies.Parse("session=abc123", "user=john", "token=xyz789")
+
+ assert.Len(t, *cookies, 3)
+ assert.Equal(t, "session", (*cookies)[0].Key)
+ assert.Equal(t, "user", (*cookies)[1].Key)
+ assert.Equal(t, "token", (*cookies)[2].Key)
+ })
+
+ t.Run("Parse cookies with same key", func(t *testing.T) {
+ cookies := &Cookies{}
+ cookies.Parse("pref=dark", "pref=large", "pref=en")
+
+ assert.Len(t, *cookies, 1)
+ assert.Equal(t, []string{"dark", "large", "en"}, (*cookies)[0].Value)
+ })
+
+ t.Run("Parse cookie without value", func(t *testing.T) {
+ cookies := &Cookies{}
+ cookies.Parse("sessionid")
+
+ assert.Len(t, *cookies, 1)
+ assert.Equal(t, "sessionid", (*cookies)[0].Key)
+ assert.Equal(t, []string{""}, (*cookies)[0].Value)
+ })
+
+ t.Run("Parse cookie with empty value", func(t *testing.T) {
+ cookies := &Cookies{}
+ cookies.Parse("empty=")
+
+ assert.Len(t, *cookies, 1)
+ assert.Equal(t, "empty", (*cookies)[0].Key)
+ assert.Equal(t, []string{""}, (*cookies)[0].Value)
+ })
+
+ t.Run("Parse cookie with multiple equals", func(t *testing.T) {
+ cookies := &Cookies{}
+ cookies.Parse("data=key=value=test")
+
+ assert.Len(t, *cookies, 1)
+ assert.Equal(t, "data", (*cookies)[0].Key)
+ assert.Equal(t, []string{"key=value=test"}, (*cookies)[0].Value)
+ })
+
+ t.Run("Parse no arguments", func(t *testing.T) {
+ cookies := &Cookies{}
+ cookies.Parse()
+
+ assert.Empty(t, *cookies)
+ })
+
+ t.Run("Parse with existing cookies", func(t *testing.T) {
+ cookies := &Cookies{
+ {Key: "existing", Value: []string{"value"}},
+ }
+ cookies.Parse("new=cookie")
+
+ assert.Len(t, *cookies, 2)
+ assert.Equal(t, "existing", (*cookies)[0].Key)
+ assert.Equal(t, "new", (*cookies)[1].Key)
+ })
+}
+
+func TestParseCookie(t *testing.T) {
+ t.Run("ParseCookie with key and value", func(t *testing.T) {
+ cookie := ParseCookie("session=abc123")
+ require.NotNil(t, cookie)
+ assert.Equal(t, "session", cookie.Key)
+ assert.Equal(t, []string{"abc123"}, cookie.Value)
+ })
+
+ t.Run("ParseCookie with only key", func(t *testing.T) {
+ cookie := ParseCookie("sessionid")
+ require.NotNil(t, cookie)
+ assert.Equal(t, "sessionid", cookie.Key)
+ assert.Equal(t, []string{""}, cookie.Value)
+ })
+
+ t.Run("ParseCookie with empty value", func(t *testing.T) {
+ cookie := ParseCookie("key=")
+ require.NotNil(t, cookie)
+ assert.Equal(t, "key", cookie.Key)
+ assert.Equal(t, []string{""}, cookie.Value)
+ })
+
+ t.Run("ParseCookie with multiple equals", func(t *testing.T) {
+ cookie := ParseCookie("data=base64=encoded=value")
+ require.NotNil(t, cookie)
+ assert.Equal(t, "data", cookie.Key)
+ assert.Equal(t, []string{"base64=encoded=value"}, cookie.Value)
+ })
+
+ t.Run("ParseCookie with empty string", func(t *testing.T) {
+ cookie := ParseCookie("")
+ require.NotNil(t, cookie)
+ assert.Empty(t, cookie.Key)
+ assert.Equal(t, []string{""}, cookie.Value)
+ })
+
+ t.Run("ParseCookie with spaces", func(t *testing.T) {
+ cookie := ParseCookie("key with spaces=value with spaces")
+ require.NotNil(t, cookie)
+ assert.Equal(t, "key with spaces", cookie.Key)
+ assert.Equal(t, []string{"value with spaces"}, cookie.Value)
+ })
+
+ t.Run("ParseCookie with special characters", func(t *testing.T) {
+ cookie := ParseCookie("key-._~=val!@#$%^&*()")
+ require.NotNil(t, cookie)
+ assert.Equal(t, "key-._~", cookie.Key)
+ assert.Equal(t, []string{"val!@#$%^&*()"}, cookie.Value)
+ })
+
+ t.Run("ParseCookie with URL encoded value", func(t *testing.T) {
+ cookie := ParseCookie("data=hello%20world%3D%26")
+ require.NotNil(t, cookie)
+ assert.Equal(t, "data", cookie.Key)
+ assert.Equal(t, []string{"hello%20world%3D%26"}, cookie.Value)
+ })
+}
diff --git a/pkg/types/errors.go b/pkg/types/errors.go
new file mode 100644
index 0000000..963180b
--- /dev/null
+++ b/pkg/types/errors.go
@@ -0,0 +1,113 @@
+package types
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+)
+
+var (
+ // General
+ ErrNoError = errors.New("no error (internal)")
+
+ // CLI
+ ErrCLINoArgs = errors.New("CLI expects arguments but received none")
+ ErrCLIUnexpectedArgs = errors.New("CLI received unexpected arguments")
+
+ // Config File
+ ErrConfigFileExtensionNotFound = errors.New("config file extension not found")
+)
+
+// ======================================== General ========================================
+
+type FieldParseError struct {
+ Field string
+ Err error
+}
+
+func NewFieldParseError(field string, err error) *FieldParseError {
+ if err == nil {
+ err = ErrNoError
+ }
+ return &FieldParseError{field, err}
+}
+
+func (e FieldParseError) Error() string {
+ return fmt.Sprintf("Field '%s' parse failed: %v", e.Field, e.Err)
+}
+
+func (e FieldParseError) Unwrap() error {
+ return e.Err
+}
+
+type FieldParseErrors struct {
+ Errors []FieldParseError
+}
+
+func NewFieldParseErrors(fieldParseErrors []FieldParseError) FieldParseErrors {
+ return FieldParseErrors{fieldParseErrors}
+}
+
+func (e FieldParseErrors) Error() string {
+ if len(e.Errors) == 0 {
+ return "No field parse errors"
+ }
+ if len(e.Errors) == 1 {
+ return e.Errors[0].Error()
+ }
+
+ errorString := ""
+ for _, err := range e.Errors {
+ errorString += err.Error() + "\n"
+ }
+ errorString, _ = strings.CutSuffix(errorString, "\n")
+
+ return errorString
+}
+
+// ======================================== CLI ========================================
+
+type CLIUnexpectedArgsError struct {
+ Args []string
+}
+
+func NewCLIUnexpectedArgsError(args []string) CLIUnexpectedArgsError {
+ return CLIUnexpectedArgsError{args}
+}
+
+func (e CLIUnexpectedArgsError) Error() string {
+ return fmt.Sprintf("CLI received unexpected arguments: %v", strings.Join(e.Args, ","))
+}
+
+// ======================================== Config File ========================================
+
+type RemoteConfigFileParseError struct {
+ error error
+}
+
+func NewRemoteConfigFileParseError(err error) *RemoteConfigFileParseError {
+ if err == nil {
+ err = ErrNoError
+ }
+ return &RemoteConfigFileParseError{err}
+}
+
+func (e RemoteConfigFileParseError) Error() string {
+ return "Remote config file parse error: " + e.error.Error()
+}
+
+func (e RemoteConfigFileParseError) Unwrap() error {
+ return e.error
+}
+
+type UnknownConfigFileTypeError struct {
+ Type string
+}
+
+func NewUnknownConfigFileTypeError(_type string) *UnknownConfigFileTypeError {
+ return &UnknownConfigFileTypeError{_type}
+}
+
+func (e UnknownConfigFileTypeError) Error() string {
+ return "Unknown config file type: " + e.Type
+}
diff --git a/pkg/types/errors_test.go b/pkg/types/errors_test.go
new file mode 100644
index 0000000..3788eaa
--- /dev/null
+++ b/pkg/types/errors_test.go
@@ -0,0 +1,284 @@
+package types
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFieldParseError_Error(t *testing.T) {
+ t.Run("Error returns formatted message", func(t *testing.T) {
+ originalErr := errors.New("invalid value")
+ fieldErr := NewFieldParseError("username", originalErr)
+
+ expected := "Field 'username' parse failed: invalid value"
+ assert.Equal(t, expected, fieldErr.Error())
+ })
+
+ t.Run("Error with empty field name", func(t *testing.T) {
+ originalErr := errors.New("test error")
+ fieldErr := NewFieldParseError("", originalErr)
+
+ expected := "Field '' parse failed: test error"
+ assert.Equal(t, expected, fieldErr.Error())
+ })
+
+ t.Run("Error with nil underlying error", func(t *testing.T) {
+ fieldErr := NewFieldParseError("field", nil)
+
+ expected := "Field 'field' parse failed: no error (internal)"
+ assert.Equal(t, expected, fieldErr.Error())
+ })
+}
+
+func TestFieldParseError_Unwrap(t *testing.T) {
+ t.Run("Unwrap returns original error", func(t *testing.T) {
+ originalErr := errors.New("original error")
+ fieldErr := NewFieldParseError("field", originalErr)
+
+ assert.Equal(t, originalErr, fieldErr.Unwrap())
+ })
+
+ t.Run("Unwrap with nil error", func(t *testing.T) {
+ fieldErr := NewFieldParseError("field", nil)
+
+ assert.Equal(t, ErrNoError, fieldErr.Unwrap())
+ })
+}
+
+func TestNewFieldParseError(t *testing.T) {
+ t.Run("Creates FieldParseError with correct values", func(t *testing.T) {
+ originalErr := errors.New("test error")
+ fieldErr := NewFieldParseError("testField", originalErr)
+
+ assert.Equal(t, "testField", fieldErr.Field)
+ assert.Equal(t, originalErr, fieldErr.Err)
+ })
+
+ t.Run("Creates FieldParseError with ErrNoError when nil passed", func(t *testing.T) {
+ fieldErr := NewFieldParseError("testField", nil)
+
+ assert.Equal(t, "testField", fieldErr.Field)
+ assert.Equal(t, ErrNoError, fieldErr.Err)
+ })
+}
+
+func TestFieldParseErrors_Error(t *testing.T) {
+ t.Run("Error with no errors returns default message", func(t *testing.T) {
+ fieldErrors := NewFieldParseErrors([]FieldParseError{})
+
+ assert.Equal(t, "No field parse errors", fieldErrors.Error())
+ })
+
+ t.Run("Error with single error returns single error message", func(t *testing.T) {
+ fieldErr := *NewFieldParseError("field1", errors.New("error1"))
+ fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr})
+
+ expected := "Field 'field1' parse failed: error1"
+ assert.Equal(t, expected, fieldErrors.Error())
+ })
+
+ t.Run("Error with multiple errors returns concatenated messages", func(t *testing.T) {
+ fieldErr1 := *NewFieldParseError("field1", errors.New("error1"))
+ fieldErr2 := *NewFieldParseError("field2", errors.New("error2"))
+ fieldErr3 := *NewFieldParseError("field3", errors.New("error3"))
+ fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2, fieldErr3})
+
+ expected := "Field 'field1' parse failed: error1\nField 'field2' parse failed: error2\nField 'field3' parse failed: error3"
+ assert.Equal(t, expected, fieldErrors.Error())
+ })
+
+ t.Run("Error with two errors", func(t *testing.T) {
+ fieldErr1 := *NewFieldParseError("username", errors.New("too short"))
+ fieldErr2 := *NewFieldParseError("email", errors.New("invalid format"))
+ fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2})
+
+ expected := "Field 'username' parse failed: too short\nField 'email' parse failed: invalid format"
+ assert.Equal(t, expected, fieldErrors.Error())
+ })
+}
+
+func TestNewFieldParseErrors(t *testing.T) {
+ t.Run("Creates FieldParseErrors with correct values", func(t *testing.T) {
+ fieldErr1 := *NewFieldParseError("field1", errors.New("error1"))
+ fieldErr2 := *NewFieldParseError("field2", errors.New("error2"))
+ fieldErrors := NewFieldParseErrors([]FieldParseError{fieldErr1, fieldErr2})
+
+ assert.Len(t, fieldErrors.Errors, 2)
+ assert.Equal(t, fieldErr1, fieldErrors.Errors[0])
+ assert.Equal(t, fieldErr2, fieldErrors.Errors[1])
+ })
+
+ t.Run("Creates FieldParseErrors with empty slice", func(t *testing.T) {
+ fieldErrors := NewFieldParseErrors([]FieldParseError{})
+
+ assert.Empty(t, fieldErrors.Errors)
+ })
+}
+
+func TestCLIUnexpectedArgsError_Error(t *testing.T) {
+ t.Run("Error with single argument", func(t *testing.T) {
+ err := NewCLIUnexpectedArgsError([]string{"arg1"})
+
+ expected := "CLI received unexpected arguments: arg1"
+ assert.Equal(t, expected, err.Error())
+ })
+
+ t.Run("Error with multiple arguments", func(t *testing.T) {
+ err := NewCLIUnexpectedArgsError([]string{"arg1", "arg2", "arg3"})
+
+ expected := "CLI received unexpected arguments: arg1,arg2,arg3"
+ assert.Equal(t, expected, err.Error())
+ })
+
+ t.Run("Error with empty arguments", func(t *testing.T) {
+ err := NewCLIUnexpectedArgsError([]string{})
+
+ expected := "CLI received unexpected arguments: "
+ assert.Equal(t, expected, err.Error())
+ })
+
+ t.Run("Error with arguments containing special characters", func(t *testing.T) {
+ err := NewCLIUnexpectedArgsError([]string{"--flag", "value with spaces", "-x"})
+
+ expected := "CLI received unexpected arguments: --flag,value with spaces,-x"
+ assert.Equal(t, expected, err.Error())
+ })
+}
+
+func TestNewCLIUnexpectedArgsError(t *testing.T) {
+ t.Run("Creates CLIUnexpectedArgsError with correct values", func(t *testing.T) {
+ args := []string{"arg1", "arg2"}
+ err := NewCLIUnexpectedArgsError(args)
+
+ assert.Equal(t, args, err.Args)
+ })
+}
+
+func TestRemoteConfigFileParseError_Error(t *testing.T) {
+ t.Run("Error returns formatted message", func(t *testing.T) {
+ originalErr := errors.New("invalid URL")
+ err := NewRemoteConfigFileParseError(originalErr)
+
+ expected := "Remote config file parse error: invalid URL"
+ assert.Equal(t, expected, err.Error())
+ })
+
+ t.Run("Error with nil underlying error", func(t *testing.T) {
+ err := NewRemoteConfigFileParseError(nil)
+
+ expected := "Remote config file parse error: no error (internal)"
+ assert.Equal(t, expected, err.Error())
+ })
+}
+
+func TestRemoteConfigFileParseError_Unwrap(t *testing.T) {
+ t.Run("Unwrap returns original error", func(t *testing.T) {
+ originalErr := errors.New("original error")
+ err := NewRemoteConfigFileParseError(originalErr)
+
+ assert.Equal(t, originalErr, err.Unwrap())
+ })
+
+ t.Run("Unwrap with nil error", func(t *testing.T) {
+ err := NewRemoteConfigFileParseError(nil)
+
+ assert.Equal(t, ErrNoError, err.Unwrap())
+ })
+}
+
+func TestNewRemoteConfigFileParseError(t *testing.T) {
+ t.Run("Creates RemoteConfigFileParseError with correct values", func(t *testing.T) {
+ originalErr := errors.New("test error")
+ err := NewRemoteConfigFileParseError(originalErr)
+
+ assert.Equal(t, originalErr, err.error)
+ })
+
+ t.Run("Creates RemoteConfigFileParseError with ErrNoError when nil passed", func(t *testing.T) {
+ err := NewRemoteConfigFileParseError(nil)
+
+ assert.Equal(t, ErrNoError, err.error)
+ })
+}
+
+func TestUnknownConfigFileTypeError_Error(t *testing.T) {
+ t.Run("Error returns formatted message", func(t *testing.T) {
+ err := NewUnknownConfigFileTypeError("json")
+
+ expected := "Unknown config file type: json"
+ assert.Equal(t, expected, err.Error())
+ })
+
+ t.Run("Error with empty type", func(t *testing.T) {
+ err := NewUnknownConfigFileTypeError("")
+
+ expected := "Unknown config file type: "
+ assert.Equal(t, expected, err.Error())
+ })
+
+ t.Run("Error with special characters in type", func(t *testing.T) {
+ err := NewUnknownConfigFileTypeError("type.with.dots")
+
+ expected := "Unknown config file type: type.with.dots"
+ assert.Equal(t, expected, err.Error())
+ })
+}
+
+func TestNewUnknownConfigFileTypeError(t *testing.T) {
+ t.Run("Creates UnknownConfigFileTypeError with correct values", func(t *testing.T) {
+ err := NewUnknownConfigFileTypeError("xml")
+
+ assert.Equal(t, "xml", err.Type)
+ })
+}
+
+func TestErrorConstants(t *testing.T) {
+ t.Run("ErrNoError has correct message", func(t *testing.T) {
+ expected := "no error (internal)"
+ assert.Equal(t, expected, ErrNoError.Error())
+ })
+
+ t.Run("ErrCLINoArgs has correct message", func(t *testing.T) {
+ expected := "CLI expects arguments but received none"
+ assert.Equal(t, expected, ErrCLINoArgs.Error())
+ })
+
+ t.Run("ErrCLIUnexpectedArgs has correct message", func(t *testing.T) {
+ expected := "CLI received unexpected arguments"
+ assert.Equal(t, expected, ErrCLIUnexpectedArgs.Error())
+ })
+
+ t.Run("ErrConfigFileExtensionNotFound has correct message", func(t *testing.T) {
+ expected := "config file extension not found"
+ assert.Equal(t, expected, ErrConfigFileExtensionNotFound.Error())
+ })
+}
+
+func TestErrorImplementsErrorInterface(t *testing.T) {
+ t.Run("FieldParseError implements error interface", func(t *testing.T) {
+ var err error = NewFieldParseError("field", errors.New("test"))
+ assert.Error(t, err)
+ })
+
+ t.Run("FieldParseErrors implements error interface", func(t *testing.T) {
+ var err error = NewFieldParseErrors([]FieldParseError{})
+ assert.Error(t, err)
+ })
+
+ t.Run("CLIUnexpectedArgsError implements error interface", func(t *testing.T) {
+ var err error = NewCLIUnexpectedArgsError([]string{})
+ assert.Error(t, err)
+ })
+
+ t.Run("RemoteConfigFileParseError implements error interface", func(t *testing.T) {
+ var err error = NewRemoteConfigFileParseError(errors.New("test"))
+ assert.Error(t, err)
+ })
+
+ t.Run("UnknownConfigFileTypeError implements error interface", func(t *testing.T) {
+ var err error = NewUnknownConfigFileTypeError("test")
+ assert.Error(t, err)
+ })
+}
diff --git a/pkg/types/header.go b/pkg/types/header.go
new file mode 100644
index 0000000..147a68d
--- /dev/null
+++ b/pkg/types/header.go
@@ -0,0 +1,52 @@
+package types
+
+import "strings"
+
+type Header KeyValue[string, []string]
+
+type Headers []Header
+
+// Has checks if a header with the given key exists.
+func (headers Headers) Has(key string) bool {
+ for i := range headers {
+ if headers[i].Key == key {
+ return true
+ }
+ }
+ return false
+}
+
+func (headers Headers) GetValue(key string) *[]string {
+ for i := range headers {
+ if headers[i].Key == key {
+ return &headers[i].Value
+ }
+ }
+ return nil
+}
+
+func (headers *Headers) Append(header Header) {
+ if item := headers.GetValue(header.Key); item != nil {
+ *item = append(*item, header.Value...)
+ } else {
+ *headers = append(*headers, header)
+ }
+}
+
+func (headers *Headers) Parse(rawValues ...string) {
+ for _, rawValue := range rawValues {
+ headers.Append(*ParseHeader(rawValue))
+ }
+}
+
+func ParseHeader(rawValue string) *Header {
+ parts := strings.SplitN(rawValue, ": ", 2)
+ switch len(parts) {
+ case 1:
+ return &Header{Key: parts[0], Value: []string{""}}
+ case 2:
+ return &Header{Key: parts[0], Value: []string{parts[1]}}
+ default:
+ return &Header{Key: "", Value: []string{""}}
+ }
+}
diff --git a/pkg/types/header_test.go b/pkg/types/header_test.go
new file mode 100644
index 0000000..b5a9b23
--- /dev/null
+++ b/pkg/types/header_test.go
@@ -0,0 +1,277 @@
+package types
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHeaders_Has(t *testing.T) {
+ t.Run("Has returns true for existing header", func(t *testing.T) {
+ headers := Headers{
+ {Key: "Content-Type", Value: []string{"application/json"}},
+ {Key: "Authorization", Value: []string{"Bearer token"}},
+ }
+
+ assert.True(t, headers.Has("Content-Type"))
+ assert.True(t, headers.Has("Authorization"))
+ })
+
+ t.Run("Has returns false for non-existent header", func(t *testing.T) {
+ headers := Headers{
+ {Key: "Content-Type", Value: []string{"application/json"}},
+ }
+
+ assert.False(t, headers.Has("Authorization"))
+ assert.False(t, headers.Has("X-Custom-Header"))
+ })
+
+ t.Run("Has with empty headers", func(t *testing.T) {
+ headers := Headers{}
+
+ assert.False(t, headers.Has("Any-Header"))
+ })
+
+ t.Run("Has is case sensitive", func(t *testing.T) {
+ headers := Headers{
+ {Key: "Content-Type", Value: []string{"text/html"}},
+ }
+
+ assert.True(t, headers.Has("Content-Type"))
+ assert.False(t, headers.Has("content-type"))
+ assert.False(t, headers.Has("CONTENT-TYPE"))
+ })
+}
+
+func TestHeaders_GetValue(t *testing.T) {
+ t.Run("GetValue returns existing header value", func(t *testing.T) {
+ headers := Headers{
+ {Key: "Content-Type", Value: []string{"application/json"}},
+ {Key: "Accept", Value: []string{"text/html"}},
+ }
+
+ value := headers.GetValue("Content-Type")
+ require.NotNil(t, value)
+ assert.Equal(t, []string{"application/json"}, *value)
+ })
+
+ t.Run("GetValue returns nil for non-existent header", func(t *testing.T) {
+ headers := Headers{
+ {Key: "Content-Type", Value: []string{"application/json"}},
+ }
+
+ value := headers.GetValue("Authorization")
+ assert.Nil(t, value)
+ })
+
+ t.Run("GetValue with empty headers", func(t *testing.T) {
+ headers := Headers{}
+
+ value := headers.GetValue("Any-Header")
+ assert.Nil(t, value)
+ })
+
+ t.Run("GetValue with multiple values", func(t *testing.T) {
+ headers := Headers{
+ {Key: "Accept", Value: []string{"text/html", "application/xml", "application/json"}},
+ }
+
+ value := headers.GetValue("Accept")
+ require.NotNil(t, value)
+ assert.Equal(t, []string{"text/html", "application/xml", "application/json"}, *value)
+ })
+
+ t.Run("GetValue is case sensitive", func(t *testing.T) {
+ headers := Headers{
+ {Key: "X-Custom-Header", Value: []string{"value"}},
+ }
+
+ value1 := headers.GetValue("X-Custom-Header")
+ require.NotNil(t, value1)
+ assert.Equal(t, []string{"value"}, *value1)
+
+ value2 := headers.GetValue("x-custom-header")
+ assert.Nil(t, value2)
+ })
+}
+
+func TestHeaders_Append(t *testing.T) {
+ t.Run("Append new header", func(t *testing.T) {
+ headers := &Headers{}
+ headers.Append(Header{Key: "Content-Type", Value: []string{"application/json"}})
+
+ assert.Len(t, *headers, 1)
+ assert.Equal(t, "Content-Type", (*headers)[0].Key)
+ assert.Equal(t, []string{"application/json"}, (*headers)[0].Value)
+ })
+
+ t.Run("Append to existing header key", func(t *testing.T) {
+ headers := &Headers{
+ {Key: "Accept", Value: []string{"text/html"}},
+ }
+ headers.Append(Header{Key: "Accept", Value: []string{"application/json"}})
+
+ assert.Len(t, *headers, 1)
+ assert.Equal(t, []string{"text/html", "application/json"}, (*headers)[0].Value)
+ })
+
+ t.Run("Append different headers", func(t *testing.T) {
+ headers := &Headers{}
+ headers.Append(Header{Key: "Content-Type", Value: []string{"application/json"}})
+ headers.Append(Header{Key: "Authorization", Value: []string{"Bearer token"}})
+ headers.Append(Header{Key: "Accept", Value: []string{"*/*"}})
+
+ assert.Len(t, *headers, 3)
+ })
+
+ t.Run("Append multiple values at once", func(t *testing.T) {
+ headers := &Headers{
+ {Key: "Accept-Language", Value: []string{"en"}},
+ }
+ headers.Append(Header{Key: "Accept-Language", Value: []string{"fr", "de"}})
+
+ assert.Len(t, *headers, 1)
+ assert.Equal(t, []string{"en", "fr", "de"}, (*headers)[0].Value)
+ })
+
+ t.Run("Append empty value", func(t *testing.T) {
+ headers := &Headers{}
+ headers.Append(Header{Key: "Empty-Header", Value: []string{""}})
+
+ assert.Len(t, *headers, 1)
+ assert.Equal(t, []string{""}, (*headers)[0].Value)
+ })
+}
+
+func TestHeaders_Parse(t *testing.T) {
+ t.Run("Parse single header", func(t *testing.T) {
+ headers := &Headers{}
+ headers.Parse("Content-Type: application/json")
+
+ assert.Len(t, *headers, 1)
+ assert.Equal(t, "Content-Type", (*headers)[0].Key)
+ assert.Equal(t, []string{"application/json"}, (*headers)[0].Value)
+ })
+
+ t.Run("Parse multiple headers", func(t *testing.T) {
+ headers := &Headers{}
+ headers.Parse("Content-Type: application/json", "Authorization: Bearer token", "Accept: */*")
+
+ assert.Len(t, *headers, 3)
+ assert.Equal(t, "Content-Type", (*headers)[0].Key)
+ assert.Equal(t, "Authorization", (*headers)[1].Key)
+ assert.Equal(t, "Accept", (*headers)[2].Key)
+ })
+
+ t.Run("Parse headers with same key", func(t *testing.T) {
+ headers := &Headers{}
+ headers.Parse("Accept: text/html", "Accept: application/json", "Accept: application/xml")
+
+ assert.Len(t, *headers, 1)
+ assert.Equal(t, []string{"text/html", "application/json", "application/xml"}, (*headers)[0].Value)
+ })
+
+ t.Run("Parse header without value", func(t *testing.T) {
+ headers := &Headers{}
+ headers.Parse("X-Empty-Header")
+
+ assert.Len(t, *headers, 1)
+ assert.Equal(t, "X-Empty-Header", (*headers)[0].Key)
+ assert.Equal(t, []string{""}, (*headers)[0].Value)
+ })
+
+ t.Run("Parse header with empty value", func(t *testing.T) {
+ headers := &Headers{}
+ headers.Parse("X-Empty: ")
+
+ assert.Len(t, *headers, 1)
+ assert.Equal(t, "X-Empty", (*headers)[0].Key)
+ assert.Equal(t, []string{""}, (*headers)[0].Value)
+ })
+
+ t.Run("Parse header with multiple colons", func(t *testing.T) {
+ headers := &Headers{}
+ headers.Parse("X-Time: 12:34:56")
+
+ assert.Len(t, *headers, 1)
+ assert.Equal(t, "X-Time", (*headers)[0].Key)
+ assert.Equal(t, []string{"12:34:56"}, (*headers)[0].Value)
+ })
+
+ t.Run("Parse no arguments", func(t *testing.T) {
+ headers := &Headers{}
+ headers.Parse()
+
+ assert.Empty(t, *headers)
+ })
+
+ t.Run("Parse with existing headers", func(t *testing.T) {
+ headers := &Headers{
+ {Key: "Existing", Value: []string{"value"}},
+ }
+ headers.Parse("New: header")
+
+ assert.Len(t, *headers, 2)
+ assert.Equal(t, "Existing", (*headers)[0].Key)
+ assert.Equal(t, "New", (*headers)[1].Key)
+ })
+}
+
+func TestParseHeader(t *testing.T) {
+ t.Run("ParseHeader with key and value", func(t *testing.T) {
+ header := ParseHeader("Content-Type: application/json")
+ require.NotNil(t, header)
+ assert.Equal(t, "Content-Type", header.Key)
+ assert.Equal(t, []string{"application/json"}, header.Value)
+ })
+
+ t.Run("ParseHeader with only key", func(t *testing.T) {
+ header := ParseHeader("X-Header")
+ require.NotNil(t, header)
+ assert.Equal(t, "X-Header", header.Key)
+ assert.Equal(t, []string{""}, header.Value)
+ })
+
+ t.Run("ParseHeader with empty value", func(t *testing.T) {
+ header := ParseHeader("Key: ")
+ require.NotNil(t, header)
+ assert.Equal(t, "Key", header.Key)
+ assert.Equal(t, []string{""}, header.Value)
+ })
+
+ t.Run("ParseHeader with multiple colons", func(t *testing.T) {
+ header := ParseHeader("X-URL: https://example.com:8080/path")
+ require.NotNil(t, header)
+ assert.Equal(t, "X-URL", header.Key)
+ assert.Equal(t, []string{"https://example.com:8080/path"}, header.Value)
+ })
+
+ t.Run("ParseHeader with empty string", func(t *testing.T) {
+ header := ParseHeader("")
+ require.NotNil(t, header)
+ assert.Empty(t, header.Key)
+ assert.Equal(t, []string{""}, header.Value)
+ })
+
+ t.Run("ParseHeader with spaces in value", func(t *testing.T) {
+ header := ParseHeader("User-Agent: Mozilla/5.0 (Windows NT 10.0)")
+ require.NotNil(t, header)
+ assert.Equal(t, "User-Agent", header.Key)
+ assert.Equal(t, []string{"Mozilla/5.0 (Windows NT 10.0)"}, header.Value)
+ })
+
+ t.Run("ParseHeader without colon-space separator", func(t *testing.T) {
+ header := ParseHeader("Content-Type:application/json")
+ require.NotNil(t, header)
+ assert.Equal(t, "Content-Type:application/json", header.Key)
+ assert.Equal(t, []string{""}, header.Value)
+ })
+
+ t.Run("ParseHeader with trailing spaces", func(t *testing.T) {
+ header := ParseHeader("Header: value with spaces ")
+ require.NotNil(t, header)
+ assert.Equal(t, "Header", header.Key)
+ assert.Equal(t, []string{"value with spaces "}, header.Value)
+ })
+}
diff --git a/types/key_value.go b/pkg/types/key_value.go
similarity index 100%
rename from types/key_value.go
rename to pkg/types/key_value.go
diff --git a/pkg/types/param.go b/pkg/types/param.go
new file mode 100644
index 0000000..645d484
--- /dev/null
+++ b/pkg/types/param.go
@@ -0,0 +1,42 @@
+package types
+
+import "strings"
+
+type Param KeyValue[string, []string]
+
+type Params []Param
+
+func (params Params) GetValue(key string) *[]string {
+ for i := range params {
+ if params[i].Key == key {
+ return ¶ms[i].Value
+ }
+ }
+ return nil
+}
+
+func (params *Params) Append(param Param) {
+ if item := params.GetValue(param.Key); item != nil {
+ *item = append(*item, param.Value...)
+ } else {
+ *params = append(*params, param)
+ }
+}
+
+func (params *Params) Parse(rawValues ...string) {
+ for _, rawValue := range rawValues {
+ params.Append(*ParseParam(rawValue))
+ }
+}
+
+func ParseParam(rawValue string) *Param {
+ parts := strings.SplitN(rawValue, "=", 2)
+ switch len(parts) {
+ case 1:
+ return &Param{Key: parts[0], Value: []string{""}}
+ case 2:
+ return &Param{Key: parts[0], Value: []string{parts[1]}}
+ default:
+ return &Param{Key: "", Value: []string{""}}
+ }
+}
diff --git a/pkg/types/param_test.go b/pkg/types/param_test.go
new file mode 100644
index 0000000..93b2d89
--- /dev/null
+++ b/pkg/types/param_test.go
@@ -0,0 +1,281 @@
+package types
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParams_GetValue(t *testing.T) {
+ t.Run("GetValue returns existing parameter value", func(t *testing.T) {
+ params := Params{
+ {Key: "name", Value: []string{"john"}},
+ {Key: "age", Value: []string{"25"}},
+ }
+
+ value := params.GetValue("name")
+ require.NotNil(t, value)
+ assert.Equal(t, []string{"john"}, *value)
+ })
+
+ t.Run("GetValue returns nil for non-existent parameter", func(t *testing.T) {
+ params := Params{
+ {Key: "name", Value: []string{"john"}},
+ }
+
+ value := params.GetValue("nonexistent")
+ assert.Nil(t, value)
+ })
+
+ t.Run("GetValue with empty params", func(t *testing.T) {
+ params := Params{}
+
+ value := params.GetValue("any")
+ assert.Nil(t, value)
+ })
+
+ t.Run("GetValue with multiple values", func(t *testing.T) {
+ params := Params{
+ {Key: "tags", Value: []string{"go", "test", "api"}},
+ }
+
+ value := params.GetValue("tags")
+ require.NotNil(t, value)
+ assert.Equal(t, []string{"go", "test", "api"}, *value)
+ })
+
+ t.Run("GetValue case sensitive", func(t *testing.T) {
+ params := Params{
+ {Key: "Name", Value: []string{"value"}},
+ }
+
+ value1 := params.GetValue("Name")
+ require.NotNil(t, value1)
+ assert.Equal(t, []string{"value"}, *value1)
+
+ value2 := params.GetValue("name")
+ assert.Nil(t, value2)
+ })
+}
+
+func TestParams_Append(t *testing.T) {
+ t.Run("Append new parameter", func(t *testing.T) {
+ params := &Params{}
+ params.Append(Param{Key: "name", Value: []string{"john"}})
+
+ assert.Len(t, *params, 1)
+ assert.Equal(t, "name", (*params)[0].Key)
+ assert.Equal(t, []string{"john"}, (*params)[0].Value)
+ })
+
+ t.Run("Append to existing parameter key", func(t *testing.T) {
+ params := &Params{
+ {Key: "tags", Value: []string{"go"}},
+ }
+ params.Append(Param{Key: "tags", Value: []string{"test"}})
+
+ assert.Len(t, *params, 1)
+ assert.Equal(t, []string{"go", "test"}, (*params)[0].Value)
+ })
+
+ t.Run("Append different parameters", func(t *testing.T) {
+ params := &Params{}
+ params.Append(Param{Key: "name", Value: []string{"john"}})
+ params.Append(Param{Key: "age", Value: []string{"25"}})
+ params.Append(Param{Key: "city", Value: []string{"NYC"}})
+
+ assert.Len(t, *params, 3)
+ })
+
+ t.Run("Append multiple values at once", func(t *testing.T) {
+ params := &Params{
+ {Key: "colors", Value: []string{"red"}},
+ }
+ params.Append(Param{Key: "colors", Value: []string{"blue", "green"}})
+
+ assert.Len(t, *params, 1)
+ assert.Equal(t, []string{"red", "blue", "green"}, (*params)[0].Value)
+ })
+
+ t.Run("Append empty value", func(t *testing.T) {
+ params := &Params{}
+ params.Append(Param{Key: "empty", Value: []string{""}})
+
+ assert.Len(t, *params, 1)
+ assert.Equal(t, []string{""}, (*params)[0].Value)
+ })
+}
+
+func TestParams_Parse(t *testing.T) {
+ t.Run("Parse single parameter", func(t *testing.T) {
+ params := &Params{}
+ params.Parse("name=john")
+
+ assert.Len(t, *params, 1)
+ assert.Equal(t, "name", (*params)[0].Key)
+ assert.Equal(t, []string{"john"}, (*params)[0].Value)
+ })
+
+ t.Run("Parse multiple parameters", func(t *testing.T) {
+ params := &Params{}
+ params.Parse("name=john", "age=25", "city=NYC")
+
+ assert.Len(t, *params, 3)
+ assert.Equal(t, "name", (*params)[0].Key)
+ assert.Equal(t, "age", (*params)[1].Key)
+ assert.Equal(t, "city", (*params)[2].Key)
+ })
+
+ t.Run("Parse parameters with same key", func(t *testing.T) {
+ params := &Params{}
+ params.Parse("filter=name", "filter=age", "filter=city")
+
+ assert.Len(t, *params, 1)
+ assert.Equal(t, []string{"name", "age", "city"}, (*params)[0].Value)
+ })
+
+ t.Run("Parse parameter without value", func(t *testing.T) {
+ params := &Params{}
+ params.Parse("debug")
+
+ assert.Len(t, *params, 1)
+ assert.Equal(t, "debug", (*params)[0].Key)
+ assert.Equal(t, []string{""}, (*params)[0].Value)
+ })
+
+ t.Run("Parse parameter with empty value", func(t *testing.T) {
+ params := &Params{}
+ params.Parse("empty=")
+
+ assert.Len(t, *params, 1)
+ assert.Equal(t, "empty", (*params)[0].Key)
+ assert.Equal(t, []string{""}, (*params)[0].Value)
+ })
+
+ t.Run("Parse parameter with multiple equals", func(t *testing.T) {
+ params := &Params{}
+ params.Parse("equation=x=y+z")
+
+ assert.Len(t, *params, 1)
+ assert.Equal(t, "equation", (*params)[0].Key)
+ assert.Equal(t, []string{"x=y+z"}, (*params)[0].Value)
+ })
+
+ t.Run("Parse no arguments", func(t *testing.T) {
+ params := &Params{}
+ params.Parse()
+
+ assert.Empty(t, *params)
+ })
+
+ t.Run("Parse with existing parameters", func(t *testing.T) {
+ params := &Params{
+ {Key: "existing", Value: []string{"value"}},
+ }
+ params.Parse("new=param")
+
+ assert.Len(t, *params, 2)
+ assert.Equal(t, "existing", (*params)[0].Key)
+ assert.Equal(t, "new", (*params)[1].Key)
+ })
+
+ t.Run("Parse URL-encoded values", func(t *testing.T) {
+ params := &Params{}
+ params.Parse("query=hello%20world", "special=%21%40%23")
+
+ assert.Len(t, *params, 2)
+ assert.Equal(t, []string{"hello%20world"}, (*params)[0].Value)
+ assert.Equal(t, []string{"%21%40%23"}, (*params)[1].Value)
+ })
+}
+
+func TestParseParam(t *testing.T) {
+ t.Run("ParseParam with key and value", func(t *testing.T) {
+ param := ParseParam("name=john")
+ require.NotNil(t, param)
+ assert.Equal(t, "name", param.Key)
+ assert.Equal(t, []string{"john"}, param.Value)
+ })
+
+ t.Run("ParseParam with only key", func(t *testing.T) {
+ param := ParseParam("debug")
+ require.NotNil(t, param)
+ assert.Equal(t, "debug", param.Key)
+ assert.Equal(t, []string{""}, param.Value)
+ })
+
+ t.Run("ParseParam with empty value", func(t *testing.T) {
+ param := ParseParam("key=")
+ require.NotNil(t, param)
+ assert.Equal(t, "key", param.Key)
+ assert.Equal(t, []string{""}, param.Value)
+ })
+
+ t.Run("ParseParam with multiple equals", func(t *testing.T) {
+ param := ParseParam("data=key=value=test")
+ require.NotNil(t, param)
+ assert.Equal(t, "data", param.Key)
+ assert.Equal(t, []string{"key=value=test"}, param.Value)
+ })
+
+ t.Run("ParseParam with empty string", func(t *testing.T) {
+ param := ParseParam("")
+ require.NotNil(t, param)
+ assert.Empty(t, param.Key)
+ assert.Equal(t, []string{""}, param.Value)
+ })
+
+ t.Run("ParseParam with spaces", func(t *testing.T) {
+ param := ParseParam("key with spaces=value with spaces")
+ require.NotNil(t, param)
+ assert.Equal(t, "key with spaces", param.Key)
+ assert.Equal(t, []string{"value with spaces"}, param.Value)
+ })
+
+ t.Run("ParseParam with special characters", func(t *testing.T) {
+ param := ParseParam("key-._~=val!@#$%^&*()")
+ require.NotNil(t, param)
+ assert.Equal(t, "key-._~", param.Key)
+ assert.Equal(t, []string{"val!@#$%^&*()"}, param.Value)
+ })
+
+ t.Run("ParseParam with numeric values", func(t *testing.T) {
+ param := ParseParam("count=42")
+ require.NotNil(t, param)
+ assert.Equal(t, "count", param.Key)
+ assert.Equal(t, []string{"42"}, param.Value)
+ })
+
+ t.Run("ParseParam with boolean-like values", func(t *testing.T) {
+ testCases := []struct {
+ input string
+ expected string
+ }{
+ {"active=true", "true"},
+ {"enabled=false", "false"},
+ {"visible=1", "1"},
+ {"hidden=0", "0"},
+ }
+
+ for _, testCase := range testCases {
+ param := ParseParam(testCase.input)
+ require.NotNil(t, param)
+ assert.Equal(t, []string{testCase.expected}, param.Value)
+ }
+ })
+
+ t.Run("ParseParam with URL-encoded value", func(t *testing.T) {
+ param := ParseParam("message=hello%20world")
+ require.NotNil(t, param)
+ assert.Equal(t, "message", param.Key)
+ assert.Equal(t, []string{"hello%20world"}, param.Value)
+ })
+
+ t.Run("ParseParam with JSON-like value", func(t *testing.T) {
+ param := ParseParam(`data={"key":"value"}`)
+ require.NotNil(t, param)
+ assert.Equal(t, "data", param.Key)
+ assert.Equal(t, []string{`{"key":"value"}`}, param.Value)
+ })
+}
diff --git a/pkg/types/proxy.go b/pkg/types/proxy.go
new file mode 100644
index 0000000..f5be5ab
--- /dev/null
+++ b/pkg/types/proxy.go
@@ -0,0 +1,38 @@
+package types
+
+import (
+ "fmt"
+ "net/url"
+)
+
+type Proxy url.URL
+
+func (proxy Proxy) String() string {
+ return (*url.URL)(&proxy).String()
+}
+
+type Proxies []Proxy
+
+func (proxies *Proxies) Append(proxy Proxy) {
+ *proxies = append(*proxies, proxy)
+}
+
+func (proxies *Proxies) Parse(rawValue string) error {
+ parsedProxy, err := ParseProxy(rawValue)
+ if err != nil {
+ return err
+ }
+
+ proxies.Append(*parsedProxy)
+ return nil
+}
+
+func ParseProxy(rawValue string) (*Proxy, error) {
+ urlParsed, err := url.Parse(rawValue)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse proxy URL: %w", err)
+ }
+
+ proxyParsed := Proxy(*urlParsed)
+ return &proxyParsed, nil
+}
diff --git a/pkg/types/proxy_test.go b/pkg/types/proxy_test.go
new file mode 100644
index 0000000..3bcab02
--- /dev/null
+++ b/pkg/types/proxy_test.go
@@ -0,0 +1,285 @@
+package types
+
+import (
+ "net/url"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestProxy_String(t *testing.T) {
+ t.Run("Proxy String returns correct URL", func(t *testing.T) {
+ u, err := url.Parse("http://proxy.example.com:8080")
+ require.NoError(t, err)
+
+ proxy := Proxy(*u)
+ assert.Equal(t, "http://proxy.example.com:8080", proxy.String())
+ })
+
+ t.Run("Proxy String with HTTPS", func(t *testing.T) {
+ u, err := url.Parse("https://secure-proxy.example.com:443")
+ require.NoError(t, err)
+
+ proxy := Proxy(*u)
+ assert.Equal(t, "https://secure-proxy.example.com:443", proxy.String())
+ })
+
+ t.Run("Proxy String with authentication", func(t *testing.T) {
+ u, err := url.Parse("http://user:pass@proxy.example.com:8080")
+ require.NoError(t, err)
+
+ proxy := Proxy(*u)
+ assert.Equal(t, "http://user:pass@proxy.example.com:8080", proxy.String())
+ })
+
+ t.Run("Proxy String with path", func(t *testing.T) {
+ u, err := url.Parse("http://proxy.example.com:8080/proxy/path")
+ require.NoError(t, err)
+
+ proxy := Proxy(*u)
+ assert.Equal(t, "http://proxy.example.com:8080/proxy/path", proxy.String())
+ })
+
+ t.Run("Proxy String with query params", func(t *testing.T) {
+ u, err := url.Parse("http://proxy.example.com:8080/?timeout=30&retry=3")
+ require.NoError(t, err)
+
+ proxy := Proxy(*u)
+ assert.Equal(t, "http://proxy.example.com:8080/?timeout=30&retry=3", proxy.String())
+ })
+}
+
+func TestProxies_Append(t *testing.T) {
+ t.Run("Append single proxy", func(t *testing.T) {
+ proxies := &Proxies{}
+ u, err := url.Parse("http://proxy1.example.com:8080")
+ require.NoError(t, err)
+
+ proxy := Proxy(*u)
+ proxies.Append(proxy)
+
+ assert.Len(t, *proxies, 1)
+ assert.Equal(t, "http://proxy1.example.com:8080", (*proxies)[0].String())
+ })
+
+ t.Run("Append multiple proxies", func(t *testing.T) {
+ proxies := &Proxies{}
+
+ url1, err := url.Parse("http://proxy1.example.com:8080")
+ require.NoError(t, err)
+ url2, err := url.Parse("http://proxy2.example.com:8081")
+ require.NoError(t, err)
+ url3, err := url.Parse("https://proxy3.example.com:443")
+ require.NoError(t, err)
+
+ proxies.Append(Proxy(*url1))
+ proxies.Append(Proxy(*url2))
+ proxies.Append(Proxy(*url3))
+
+ assert.Len(t, *proxies, 3)
+ assert.Equal(t, "http://proxy1.example.com:8080", (*proxies)[0].String())
+ assert.Equal(t, "http://proxy2.example.com:8081", (*proxies)[1].String())
+ assert.Equal(t, "https://proxy3.example.com:443", (*proxies)[2].String())
+ })
+
+ t.Run("Append to existing proxies", func(t *testing.T) {
+ existingURL, err := url.Parse("http://existing.example.com:8080")
+ require.NoError(t, err)
+
+ proxies := &Proxies{Proxy(*existingURL)}
+
+ newURL, err := url.Parse("http://new.example.com:8081")
+ require.NoError(t, err)
+
+ proxies.Append(Proxy(*newURL))
+
+ assert.Len(t, *proxies, 2)
+ assert.Equal(t, "http://existing.example.com:8080", (*proxies)[0].String())
+ assert.Equal(t, "http://new.example.com:8081", (*proxies)[1].String())
+ })
+}
+
+func TestProxies_Parse(t *testing.T) {
+ t.Run("Parse valid proxy URL", func(t *testing.T) {
+ proxies := &Proxies{}
+ err := proxies.Parse("http://proxy.example.com:8080")
+
+ require.NoError(t, err)
+ assert.Len(t, *proxies, 1)
+ assert.Equal(t, "http://proxy.example.com:8080", (*proxies)[0].String())
+ })
+
+ t.Run("Parse HTTPS proxy URL", func(t *testing.T) {
+ proxies := &Proxies{}
+ err := proxies.Parse("https://secure-proxy.example.com:443")
+
+ require.NoError(t, err)
+ assert.Len(t, *proxies, 1)
+ assert.Equal(t, "https://secure-proxy.example.com:443", (*proxies)[0].String())
+ })
+
+ t.Run("Parse proxy URL with authentication", func(t *testing.T) {
+ proxies := &Proxies{}
+ err := proxies.Parse("http://user:pass@proxy.example.com:8080")
+
+ require.NoError(t, err)
+ assert.Len(t, *proxies, 1)
+ assert.Equal(t, "http://user:pass@proxy.example.com:8080", (*proxies)[0].String())
+ })
+
+ t.Run("Parse invalid proxy URL", func(t *testing.T) {
+ proxies := &Proxies{}
+ err := proxies.Parse("://invalid-url")
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to parse proxy URL")
+ assert.Empty(t, *proxies)
+ })
+
+ t.Run("Parse empty string", func(t *testing.T) {
+ proxies := &Proxies{}
+ err := proxies.Parse("")
+
+ require.NoError(t, err)
+ assert.Len(t, *proxies, 1)
+ assert.Empty(t, (*proxies)[0].String())
+ })
+
+ t.Run("Parse to existing proxies", func(t *testing.T) {
+ existingURL, err := url.Parse("http://existing.example.com:8080")
+ require.NoError(t, err)
+
+ proxies := &Proxies{Proxy(*existingURL)}
+ err = proxies.Parse("http://new.example.com:8081")
+
+ require.NoError(t, err)
+ assert.Len(t, *proxies, 2)
+ assert.Equal(t, "http://existing.example.com:8080", (*proxies)[0].String())
+ assert.Equal(t, "http://new.example.com:8081", (*proxies)[1].String())
+ })
+
+ t.Run("Parse proxy with special characters", func(t *testing.T) {
+ proxies := &Proxies{}
+ err := proxies.Parse("http://proxy.example.com:8080/path?param=value&other=test")
+
+ require.NoError(t, err)
+ assert.Len(t, *proxies, 1)
+ assert.Equal(t, "http://proxy.example.com:8080/path?param=value&other=test", (*proxies)[0].String())
+ })
+}
+
+func TestParseProxy(t *testing.T) {
+ t.Run("ParseProxy with valid HTTP URL", func(t *testing.T) {
+ proxy, err := ParseProxy("http://proxy.example.com:8080")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, "http://proxy.example.com:8080", proxy.String())
+ })
+
+ t.Run("ParseProxy with valid HTTPS URL", func(t *testing.T) {
+ proxy, err := ParseProxy("https://secure-proxy.example.com:443")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, "https://secure-proxy.example.com:443", proxy.String())
+ })
+
+ t.Run("ParseProxy with authentication", func(t *testing.T) {
+ proxy, err := ParseProxy("http://user:password@proxy.example.com:8080")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, "http://user:password@proxy.example.com:8080", proxy.String())
+ })
+
+ t.Run("ParseProxy with path", func(t *testing.T) {
+ proxy, err := ParseProxy("http://proxy.example.com:8080/proxy/endpoint")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, "http://proxy.example.com:8080/proxy/endpoint", proxy.String())
+ })
+
+ t.Run("ParseProxy with query parameters", func(t *testing.T) {
+ proxy, err := ParseProxy("http://proxy.example.com:8080/?timeout=30&retry=3")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, "http://proxy.example.com:8080/?timeout=30&retry=3", proxy.String())
+ })
+
+ t.Run("ParseProxy with malformed URL", func(t *testing.T) {
+ proxy, err := ParseProxy("://malformed-url")
+
+ require.Error(t, err)
+ assert.Nil(t, proxy)
+ assert.Contains(t, err.Error(), "failed to parse proxy URL")
+ })
+
+ t.Run("ParseProxy with empty string", func(t *testing.T) {
+ proxy, err := ParseProxy("")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Empty(t, proxy.String())
+ })
+
+ t.Run("ParseProxy with localhost", func(t *testing.T) {
+ proxy, err := ParseProxy("http://localhost:3128")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, "http://localhost:3128", proxy.String())
+ })
+
+ t.Run("ParseProxy with IP address", func(t *testing.T) {
+ proxy, err := ParseProxy("http://192.168.1.100:8080")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, "http://192.168.1.100:8080", proxy.String())
+ })
+
+ t.Run("ParseProxy without scheme", func(t *testing.T) {
+ proxy, err := ParseProxy("proxy.example.com:8080")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, "proxy.example.com:8080", proxy.String())
+ })
+
+ t.Run("ParseProxy with SOCKS protocol", func(t *testing.T) {
+ proxy, err := ParseProxy("socks5://proxy.example.com:1080")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, "socks5://proxy.example.com:1080", proxy.String())
+ })
+
+ t.Run("ParseProxy preserves URL components", func(t *testing.T) {
+ rawURL := "http://user:pass@proxy.example.com:8080/path?param=value#fragment"
+ proxy, err := ParseProxy(rawURL)
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, rawURL, proxy.String())
+ })
+
+ t.Run("ParseProxy with percent encoding", func(t *testing.T) {
+ proxy, err := ParseProxy("http://proxy.example.com:8080/path%20with%20spaces")
+
+ require.NoError(t, err)
+ require.NotNil(t, proxy)
+ assert.Equal(t, "http://proxy.example.com:8080/path%20with%20spaces", proxy.String())
+ })
+
+ t.Run("ParseProxy error message format", func(t *testing.T) {
+ _, err := ParseProxy("http://[invalid-ipv6")
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to parse proxy URL:")
+ assert.Contains(t, err.Error(), "missing ']' in host")
+ })
+}
diff --git a/utils/convert.go b/pkg/utils/convert.go
similarity index 100%
rename from utils/convert.go
rename to pkg/utils/convert.go
diff --git a/pkg/utils/convert_test.go b/pkg/utils/convert_test.go
new file mode 100644
index 0000000..f0c4f9f
--- /dev/null
+++ b/pkg/utils/convert_test.go
@@ -0,0 +1,155 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestToPtr(t *testing.T) {
+ t.Run("ToPtr with int", func(t *testing.T) {
+ value := 42
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, value, *ptr)
+ assert.NotSame(t, &value, ptr, "Should return a new pointer")
+ })
+
+ t.Run("ToPtr with string", func(t *testing.T) {
+ value := "test string"
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, value, *ptr)
+ })
+
+ t.Run("ToPtr with bool", func(t *testing.T) {
+ value := true
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, value, *ptr)
+ })
+
+ t.Run("ToPtr with float64", func(t *testing.T) {
+ value := 3.14159
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.InEpsilon(t, value, *ptr, 0.0001)
+ })
+
+ t.Run("ToPtr with struct", func(t *testing.T) {
+ type TestStruct struct {
+ Field1 string
+ Field2 int
+ }
+
+ value := TestStruct{Field1: "test", Field2: 123}
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, value, *ptr)
+ assert.Equal(t, "test", ptr.Field1)
+ assert.Equal(t, 123, ptr.Field2)
+ })
+
+ t.Run("ToPtr with slice", func(t *testing.T) {
+ value := []int{1, 2, 3}
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, value, *ptr)
+ assert.Len(t, *ptr, 3)
+ })
+
+ t.Run("ToPtr with map", func(t *testing.T) {
+ value := map[string]int{"one": 1, "two": 2}
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, value, *ptr)
+ assert.Len(t, *ptr, 2)
+ })
+
+ t.Run("ToPtr with nil interface", func(t *testing.T) {
+ var value any = nil
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Nil(t, *ptr)
+ })
+
+ t.Run("ToPtr with pointer", func(t *testing.T) {
+ originalValue := 42
+ originalPtr := &originalValue
+ ptr := ToPtr(originalPtr)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, originalPtr, *ptr)
+ assert.NotSame(t, originalPtr, ptr, "Should return a pointer to pointer")
+ })
+
+ t.Run("ToPtr with uint", func(t *testing.T) {
+ value := uint(100)
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, value, *ptr)
+ })
+
+ t.Run("ToPtr modification safety", func(t *testing.T) {
+ value := 10
+ ptr := ToPtr(value)
+
+ *ptr = 20
+ assert.Equal(t, 10, value, "Original value should not be modified")
+ assert.Equal(t, 20, *ptr, "Pointer value should be modified")
+ })
+
+ t.Run("ToPtr with byte array", func(t *testing.T) {
+ value := [3]byte{1, 2, 3}
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, value, *ptr)
+ })
+
+ t.Run("ToPtr with rune", func(t *testing.T) {
+ value := 'A'
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, value, *ptr)
+ assert.Equal(t, int32(65), *ptr)
+ })
+
+ t.Run("ToPtr with empty string", func(t *testing.T) {
+ value := ""
+ ptr := ToPtr(value)
+
+ require.NotNil(t, ptr)
+ assert.Equal(t, value, *ptr)
+ assert.Empty(t, *ptr)
+ })
+
+ t.Run("ToPtr with zero values", func(t *testing.T) {
+ // Test with various zero values
+ intZero := 0
+ intPtr := ToPtr(intZero)
+ require.NotNil(t, intPtr)
+ assert.Equal(t, 0, *intPtr)
+
+ boolZero := false
+ boolPtr := ToPtr(boolZero)
+ require.NotNil(t, boolPtr)
+ assert.False(t, *boolPtr)
+
+ floatZero := 0.0
+ floatPtr := ToPtr(floatZero)
+ require.NotNil(t, floatPtr)
+ assert.Equal(t, 0.0, *floatPtr) //nolint:testifylint
+ })
+}
diff --git a/pkg/utils/error.go b/pkg/utils/error.go
new file mode 100644
index 0000000..ebbf33c
--- /dev/null
+++ b/pkg/utils/error.go
@@ -0,0 +1,105 @@
+package utils
+
+import (
+ "errors"
+ "fmt"
+ "reflect"
+)
+
+// ErrorHandler represents a function that handles a specific error type
+type ErrorHandler func(error) error
+
+// ErrorMatcher holds the error type/value and its handler
+type ErrorMatcher struct {
+ ErrorType any // Can be error value (sentinel) or error type
+ Handler ErrorHandler
+ IsSentinel bool // true for sentinel errors, false for custom types
+}
+
+// HandleError processes an error against a list of matchers and executes the appropriate handler.
+// It returns (true, handlerResult) if a matching handler is found and executed,
+// or (false, nil) if no matcher matches the error.
+// If err is nil, returns (true, nil).
+//
+// Example:
+//
+// handled, result := HandleError(err,
+// OnSentinelError(io.EOF, func(e error) error {
+// return nil // EOF is expected, ignore it
+// }),
+// OnCustomError(func(e *CustomError) error {
+// return fmt.Errorf("custom error: %w", e)
+// }),
+// )
+func HandleError(err error, matchers ...ErrorMatcher) (bool, error) {
+ if err == nil {
+ return true, nil
+ }
+
+ for _, matcher := range matchers {
+ if matcher.IsSentinel {
+ // Handle sentinel errors with errors.Is
+ if sentinelErr, ok := matcher.ErrorType.(error); ok {
+ if errors.Is(err, sentinelErr) {
+ return true, matcher.Handler(err)
+ }
+ }
+ } else {
+ // Handle custom error types with errors.As
+ errorType := reflect.TypeOf(matcher.ErrorType)
+ errorValue := reflect.New(errorType).Interface()
+
+ if errors.As(err, errorValue) {
+ return true, matcher.Handler(err)
+ }
+ }
+ }
+
+ return false, nil // No matcher found
+}
+
+// HandleErrorOrDie processes an error against a list of matchers and executes the appropriate handler.
+// If a matching handler is found, it returns the handler's result.
+// If no matcher matches the error, it panics with a descriptive message.
+// This function is useful when all expected error types must be handled explicitly.
+//
+// Example:
+//
+// result := HandleErrorOrDie(err,
+// OnSentinelError(context.Canceled, func(e error) error {
+// return fmt.Errorf("operation canceled")
+// }),
+// OnCustomError(func(e *ValidationError) error {
+// return fmt.Errorf("validation failed: %w", e)
+// }),
+// ) // Panics if err doesn't match any handler
+func HandleErrorOrDie(err error, matchers ...ErrorMatcher) error {
+ ok, err := HandleError(err, matchers...)
+ if !ok {
+ panic(fmt.Sprintf("Unhandled error of type %T: %v", err, err))
+ }
+ return err
+}
+
+func OnSentinelError(sentinelErr error, handler ErrorHandler) ErrorMatcher {
+ return ErrorMatcher{
+ ErrorType: sentinelErr,
+ Handler: handler,
+ IsSentinel: true,
+ }
+}
+
+func OnCustomError[T error](handler func(T) error) ErrorMatcher {
+ var zero T
+ return ErrorMatcher{
+ ErrorType: zero,
+ Handler: func(err error) error {
+ var typedErr T
+ if errors.As(err, &typedErr) {
+ return handler(typedErr)
+ }
+ return nil
+ },
+ IsSentinel: false,
+ }
+}
diff --git a/pkg/utils/error_test.go b/pkg/utils/error_test.go
new file mode 100644
index 0000000..832774e
--- /dev/null
+++ b/pkg/utils/error_test.go
@@ -0,0 +1,386 @@
+package utils
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Custom error types for testing
+type CustomError struct {
+ Code int
+ Message string
+}
+
+func (e *CustomError) Error() string {
+ return fmt.Sprintf("custom error %d: %s", e.Code, e.Message)
+}
+
+type ValidationError struct {
+ Field string
+ Value string
+}
+
+func (e *ValidationError) Error() string {
+ return fmt.Sprintf("validation failed for field %s with value %s", e.Field, e.Value)
+}
+
+// Sentinel errors for testing
+var (
+ ErrSentinel1 = errors.New("sentinel error 1")
+ ErrSentinel2 = errors.New("sentinel error 2")
+)
+
+func TestHandleError(t *testing.T) {
+ t.Run("HandleError with nil error", func(t *testing.T) {
+ handled, result := HandleError(nil)
+ assert.True(t, handled)
+ assert.NoError(t, result)
+ })
+
+ t.Run("HandleError with sentinel error match", func(t *testing.T) {
+ err := io.EOF
+ handled, result := HandleError(err,
+ OnSentinelError(io.EOF, func(e error) error {
+ return errors.New("handled EOF")
+ }),
+ )
+ assert.True(t, handled)
+ assert.EqualError(t, result, "handled EOF")
+ })
+
+ t.Run("HandleError with wrapped sentinel error", func(t *testing.T) {
+ wrappedErr := fmt.Errorf("wrapped: %w", io.EOF)
+ handled, result := HandleError(wrappedErr,
+ OnSentinelError(io.EOF, func(e error) error {
+ return errors.New("handled wrapped EOF")
+ }),
+ )
+ assert.True(t, handled)
+ assert.EqualError(t, result, "handled wrapped EOF")
+ })
+
+ t.Run("HandleError with custom error type match", func(t *testing.T) {
+ err := &CustomError{Code: 404, Message: "not found"}
+ handled, result := HandleError(err,
+ OnCustomError(func(e *CustomError) error {
+ return fmt.Errorf("handled custom error with code %d", e.Code)
+ }),
+ )
+ assert.True(t, handled)
+ assert.EqualError(t, result, "handled custom error with code 404")
+ })
+
+ t.Run("HandleError with wrapped custom error", func(t *testing.T) {
+ customErr := &CustomError{Code: 500, Message: "internal error"}
+ wrappedErr := fmt.Errorf("wrapped: %w", customErr)
+
+ handled, result := HandleError(wrappedErr,
+ OnCustomError(func(e *CustomError) error {
+ return fmt.Errorf("handled wrapped custom error: %s", e.Message)
+ }),
+ )
+ assert.True(t, handled)
+ assert.EqualError(t, result, "handled wrapped custom error: internal error")
+ })
+
+ t.Run("HandleError with no matching handler", func(t *testing.T) {
+ err := errors.New("unhandled error")
+ handled, result := HandleError(err,
+ OnSentinelError(io.EOF, func(e error) error {
+ return nil
+ }),
+ OnCustomError(func(e *CustomError) error {
+ return nil
+ }),
+ )
+ assert.False(t, handled)
+ assert.NoError(t, result)
+ })
+
+ t.Run("HandleError with multiple matchers first match wins", func(t *testing.T) {
+ err := io.EOF
+ handled, result := HandleError(err,
+ OnSentinelError(io.EOF, func(e error) error {
+ return errors.New("first handler")
+ }),
+ OnSentinelError(io.EOF, func(e error) error {
+ return errors.New("second handler")
+ }),
+ )
+ assert.True(t, handled)
+ assert.EqualError(t, result, "first handler")
+ })
+
+ t.Run("HandleError with handler returning nil", func(t *testing.T) {
+ err := io.EOF
+ handled, result := HandleError(err,
+ OnSentinelError(io.EOF, func(e error) error {
+ return nil
+ }),
+ )
+ assert.True(t, handled)
+ assert.NoError(t, result)
+ })
+
+ t.Run("HandleError with multiple error types", func(t *testing.T) {
+ customErr := &CustomError{Code: 400, Message: "bad request"}
+ validationErr := &ValidationError{Field: "email", Value: "invalid"}
+
+ // Test CustomError handling
+ handled1, result1 := HandleError(customErr,
+ OnCustomError(func(e *CustomError) error {
+ return fmt.Errorf("custom: %d", e.Code)
+ }),
+ OnCustomError(func(e *ValidationError) error {
+ return fmt.Errorf("validation: %s", e.Field)
+ }),
+ )
+ assert.True(t, handled1)
+ require.EqualError(t, result1, "custom: 400")
+
+ // Test ValidationError handling
+ handled2, result2 := HandleError(validationErr,
+ OnCustomError(func(e *CustomError) error {
+ return fmt.Errorf("custom: %d", e.Code)
+ }),
+ OnCustomError(func(e *ValidationError) error {
+ return fmt.Errorf("validation: %s", e.Field)
+ }),
+ )
+ assert.True(t, handled2)
+ assert.EqualError(t, result2, "validation: email")
+ })
+
+ t.Run("HandleError with context errors", func(t *testing.T) {
+ // Test context.Canceled
+ handled1, result1 := HandleError(context.Canceled,
+ OnSentinelError(context.Canceled, func(e error) error {
+ return errors.New("operation canceled")
+ }),
+ )
+ assert.True(t, handled1)
+ require.EqualError(t, result1, "operation canceled")
+
+ // Test context.DeadlineExceeded
+ handled2, result2 := HandleError(context.DeadlineExceeded,
+ OnSentinelError(context.DeadlineExceeded, func(e error) error {
+ return errors.New("deadline exceeded")
+ }),
+ )
+ assert.True(t, handled2)
+ assert.EqualError(t, result2, "deadline exceeded")
+ })
+
+ t.Run("HandleError preserves original error in handler", func(t *testing.T) {
+ originalErr := &CustomError{Code: 403, Message: "forbidden"}
+ var capturedErr error
+
+ handled, _ := HandleError(originalErr,
+ OnCustomError(func(e *CustomError) error {
+ capturedErr = e
+ return nil
+ }),
+ )
+
+ assert.True(t, handled)
+ assert.Equal(t, originalErr, capturedErr)
+ })
+}
+
+func TestHandleErrorOrDie(t *testing.T) {
+ t.Run("HandleErrorOrDie with nil error", func(t *testing.T) {
+ result := HandleErrorOrDie(nil)
+ assert.NoError(t, result)
+ })
+
+ t.Run("HandleErrorOrDie with matched error", func(t *testing.T) {
+ err := io.EOF
+ result := HandleErrorOrDie(err,
+ OnSentinelError(io.EOF, func(e error) error {
+ return errors.New("handled EOF in die")
+ }),
+ )
+ assert.EqualError(t, result, "handled EOF in die")
+ })
+
+ t.Run("HandleErrorOrDie panics on unmatched error", func(t *testing.T) {
+ err := errors.New("unmatched error")
+
+ assert.Panics(t, func() {
+ HandleErrorOrDie(err,
+ OnSentinelError(io.EOF, func(e error) error {
+ return nil
+ }),
+ )
+ })
+ })
+
+ t.Run("HandleErrorOrDie with custom error panic", func(t *testing.T) {
+ customErr := &CustomError{Code: 500, Message: "server error"}
+
+ assert.Panics(t, func() {
+ HandleErrorOrDie(customErr,
+ OnCustomError(func(e *ValidationError) error {
+ return nil
+ }),
+ )
+ })
+ })
+
+ t.Run("HandleErrorOrDie with multiple matchers", func(t *testing.T) {
+ validationErr := &ValidationError{Field: "username", Value: ""}
+
+ result := HandleErrorOrDie(validationErr,
+ OnSentinelError(io.EOF, func(e error) error {
+ return errors.New("EOF handler")
+ }),
+ OnCustomError(func(e *CustomError) error {
+ return errors.New("custom handler")
+ }),
+ OnCustomError(func(e *ValidationError) error {
+ return fmt.Errorf("validation handler: field=%s", e.Field)
+ }),
+ )
+
+ assert.EqualError(t, result, "validation handler: field=username")
+ })
+}
+
+func TestOnSentinelError(t *testing.T) {
+ t.Run("OnSentinelError creates proper matcher", func(t *testing.T) {
+ handler := func(e error) error { return e }
+ matcher := OnSentinelError(io.EOF, handler)
+
+ assert.Equal(t, io.EOF, matcher.ErrorType)
+ assert.True(t, matcher.IsSentinel)
+ assert.NotNil(t, matcher.Handler)
+ })
+
+ t.Run("OnSentinelError with custom sentinel", func(t *testing.T) {
+ customSentinel := errors.New("custom sentinel")
+ callCount := 0
+
+ matcher := OnSentinelError(customSentinel, func(e error) error {
+ callCount++
+ return errors.New("handled custom sentinel")
+ })
+
+ // Test that it matches the sentinel
+ handled, result := HandleError(customSentinel, matcher)
+ assert.True(t, handled)
+ require.EqualError(t, result, "handled custom sentinel")
+ assert.Equal(t, 1, callCount)
+
+ // Test that it matches wrapped sentinel
+ wrappedErr := fmt.Errorf("wrapped: %w", customSentinel)
+ handled, result = HandleError(wrappedErr, matcher)
+ assert.True(t, handled)
+ require.EqualError(t, result, "handled custom sentinel")
+ assert.Equal(t, 2, callCount)
+ })
+}
+
+func TestOnCustomError(t *testing.T) {
+ t.Run("OnCustomError creates proper matcher", func(t *testing.T) {
+ matcher := OnCustomError(func(e *CustomError) error {
+ return fmt.Errorf("handled: %d", e.Code)
+ })
+
+ assert.False(t, matcher.IsSentinel)
+ assert.NotNil(t, matcher.Handler)
+
+ // Test the handler works
+ err := &CustomError{Code: 200, Message: "ok"}
+ result := matcher.Handler(err)
+ assert.EqualError(t, result, "handled: 200")
+ })
+
+ t.Run("OnCustomError with different error types", func(t *testing.T) {
+ // Create matchers for different types
+ customMatcher := OnCustomError(func(e *CustomError) error {
+ return fmt.Errorf("custom error: code=%d", e.Code)
+ })
+
+ validationMatcher := OnCustomError(func(e *ValidationError) error {
+ return fmt.Errorf("validation error: field=%s", e.Field)
+ })
+
+ // Test with CustomError
+ customErr := &CustomError{Code: 404, Message: "not found"}
+ handled, result := HandleError(customErr, customMatcher, validationMatcher)
+ assert.True(t, handled)
+ require.EqualError(t, result, "custom error: code=404")
+
+ // Test with ValidationError
+ validationErr := &ValidationError{Field: "age", Value: "-1"}
+ handled, result = HandleError(validationErr, customMatcher, validationMatcher)
+ assert.True(t, handled)
+ assert.EqualError(t, result, "validation error: field=age")
+ })
+
+ t.Run("OnCustomError handler receives correct type", func(t *testing.T) {
+ var receivedErr *CustomError
+
+ matcher := OnCustomError(func(e *CustomError) error {
+ receivedErr = e
+ return nil
+ })
+
+ originalErr := &CustomError{Code: 301, Message: "redirect"}
+ handled, _ := HandleError(originalErr, matcher)
+
+ assert.True(t, handled)
+ require.NotNil(t, receivedErr)
+ assert.Equal(t, 301, receivedErr.Code)
+ assert.Equal(t, "redirect", receivedErr.Message)
+ })
+}
+
+func TestErrorMatcherEdgeCases(t *testing.T) {
+ t.Run("Invalid sentinel error type in matcher", func(t *testing.T) {
+ // Create a matcher with invalid ErrorType for sentinel
+ matcher := ErrorMatcher{
+ ErrorType: "not an error", // Invalid type
+ Handler: func(e error) error { return e },
+ IsSentinel: true,
+ }
+
+ err := errors.New("test error")
+ handled, result := HandleError(err, matcher)
+ assert.False(t, handled)
+ assert.NoError(t, result)
+ })
+
+ t.Run("Handler that panics", func(t *testing.T) {
+ matcher := OnSentinelError(io.EOF, func(e error) error {
+ panic("handler panic")
+ })
+
+ assert.Panics(t, func() {
+ HandleError(io.EOF, matcher)
+ })
+ })
+
+ t.Run("Complex error chain", func(t *testing.T) {
+ // Create a complex error chain
+ baseErr := &CustomError{Code: 500, Message: "base"}
+ wrapped1 := fmt.Errorf("layer1: %w", baseErr)
+ wrapped2 := fmt.Errorf("layer2: %w", wrapped1)
+ wrapped3 := fmt.Errorf("layer3: %w", wrapped2)
+
+ handled, result := HandleError(wrapped3,
+ OnCustomError(func(e *CustomError) error {
+ return fmt.Errorf("found custom error at code %d", e.Code)
+ }),
+ )
+
+ assert.True(t, handled)
+ assert.EqualError(t, result, "found custom error at code 500")
+ })
+}
diff --git a/pkg/utils/print.go b/pkg/utils/print.go
new file mode 100644
index 0000000..0ab7480
--- /dev/null
+++ b/pkg/utils/print.go
@@ -0,0 +1,17 @@
+package utils
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/jedib0t/go-pretty/v6/text"
+)
+
+func PrintErr(color text.Color, format string, a ...any) {
+ fmt.Fprintln(os.Stderr, color.Sprintf(format, a...))
+}
+
+func PrintErrAndExit(color text.Color, exitCode int, format string, a ...any) {
+ PrintErr(color, format, a...)
+ os.Exit(exitCode)
+}
diff --git a/pkg/utils/print_test.go b/pkg/utils/print_test.go
new file mode 100644
index 0000000..e5657a6
--- /dev/null
+++ b/pkg/utils/print_test.go
@@ -0,0 +1,250 @@
+package utils
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "testing"
+
+ "github.com/jedib0t/go-pretty/v6/text"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrintErr(t *testing.T) {
+ t.Run("PrintErr writes to stderr with color", func(t *testing.T) {
+ // Capture stderr
+ oldStderr := os.Stderr
+ reader, writer, _ := os.Pipe()
+ os.Stderr = writer
+
+ // Call PrintErr
+ PrintErr(text.FgRed, "Error: %s", "test error")
+
+ // Restore stderr and read output
+ writer.Close()
+ os.Stderr = oldStderr
+ var buf bytes.Buffer
+ io.Copy(&buf, reader)
+ output := buf.String()
+
+ // The output should contain the message (color codes are included)
+ assert.Contains(t, output, "test error")
+ assert.Contains(t, output, "Error:")
+ assert.True(t, strings.HasSuffix(output, "\n"))
+ })
+
+ t.Run("PrintErr with multiple format arguments", func(t *testing.T) {
+ oldStderr := os.Stderr
+ reader, writer, _ := os.Pipe()
+ os.Stderr = writer
+
+ PrintErr(text.FgYellow, "Warning: %s at line %d", "issue", 42)
+
+ writer.Close()
+ os.Stderr = oldStderr
+ var buf bytes.Buffer
+ io.Copy(&buf, reader)
+ output := buf.String()
+
+ assert.Contains(t, output, "Warning: issue at line 42")
+ })
+
+ t.Run("PrintErr with no format arguments", func(t *testing.T) {
+ oldStderr := os.Stderr
+ reader, writer, _ := os.Pipe()
+ os.Stderr = writer
+
+ PrintErr(text.FgGreen, "Simple message")
+
+ writer.Close()
+ os.Stderr = oldStderr
+ var buf bytes.Buffer
+ io.Copy(&buf, reader)
+ output := buf.String()
+
+ assert.Contains(t, output, "Simple message")
+ assert.True(t, strings.HasSuffix(output, "\n"))
+ })
+
+ t.Run("PrintErr with different colors", func(t *testing.T) {
+ colors := []text.Color{
+ text.FgRed,
+ text.FgGreen,
+ text.FgYellow,
+ text.FgBlue,
+ text.FgMagenta,
+ text.FgCyan,
+ }
+
+ for _, color := range colors {
+ oldStderr := os.Stderr
+ reader, writer, _ := os.Pipe()
+ os.Stderr = writer
+
+ PrintErr(color, "Message with color")
+
+ writer.Close()
+ os.Stderr = oldStderr
+ var buf bytes.Buffer
+ io.Copy(&buf, reader)
+ output := buf.String()
+
+ assert.Contains(t, output, "Message with color")
+ }
+ })
+
+ t.Run("PrintErr with empty string", func(t *testing.T) {
+ oldStderr := os.Stderr
+ reader, writer, _ := os.Pipe()
+ os.Stderr = writer
+
+ PrintErr(text.FgRed, "")
+
+ writer.Close()
+ os.Stderr = oldStderr
+ var buf bytes.Buffer
+ io.Copy(&buf, reader)
+ output := buf.String()
+
+ assert.Equal(t, "\n", strings.TrimPrefix(output, "\x1b[31m\x1b[0m")) // Just newline after color codes
+ })
+
+ t.Run("PrintErr with special characters", func(t *testing.T) {
+ oldStderr := os.Stderr
+ reader, writer, _ := os.Pipe()
+ os.Stderr = writer
+
+ PrintErr(text.FgRed, "Special chars: %s", "!@#$%^&*()")
+
+ writer.Close()
+ os.Stderr = oldStderr
+ var buf bytes.Buffer
+ io.Copy(&buf, reader)
+ output := buf.String()
+
+ assert.Contains(t, output, "Special chars: !@#$%^&*()")
+ })
+
+ t.Run("PrintErr with percent sign in message", func(t *testing.T) {
+ oldStderr := os.Stderr
+ reader, writer, _ := os.Pipe()
+ os.Stderr = writer
+
+ PrintErr(text.FgRed, "Progress: 100%% complete")
+
+ writer.Close()
+ os.Stderr = oldStderr
+ var buf bytes.Buffer
+ io.Copy(&buf, reader)
+ output := buf.String()
+
+ assert.Contains(t, output, "Progress: 100% complete")
+ })
+}
+
+func TestPrintErrAndExit(t *testing.T) {
+ if os.Getenv("BE_CRASHER") == "1" {
+ // This is the subprocess that will actually call PrintErrAndExit
+ exitCode := 1
+ if code := os.Getenv("EXIT_CODE"); code != "" {
+ switch code {
+ case "0":
+ exitCode = 0
+ case "1":
+ exitCode = 1
+ case "2":
+ exitCode = 2
+ }
+ }
+ PrintErrAndExit(text.FgRed, exitCode, "Error: %s", "fatal error")
+ return
+ }
+
+ t.Run("PrintErrAndExit calls os.Exit with correct code", func(t *testing.T) {
+ testCases := []struct {
+ name string
+ exitCode int
+ }{
+ {"Exit with code 0", 0},
+ {"Exit with code 1", 1},
+ {"Exit with code 2", 2},
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ ctx := context.Background()
+ cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestPrintErrAndExit")
+ cmd.Env = append(os.Environ(),
+ "BE_CRASHER=1",
+ "EXIT_CODE="+string(rune('0'+testCase.exitCode)))
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
+ err := cmd.Run()
+
+ if testCase.exitCode == 0 {
+ require.NoError(t, err)
+ } else {
+ require.Error(t, err)
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ assert.Equal(t, testCase.exitCode, exitErr.ExitCode())
+ }
+ }
+
+ // Check that error message was printed to stderr
+ assert.Contains(t, stderr.String(), "Error: fatal error")
+ })
+ }
+ })
+
+ t.Run("PrintErrAndExit prints before exiting", func(t *testing.T) {
+ ctx := context.Background()
+ cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestPrintErrAndExit")
+ cmd.Env = append(os.Environ(), "BE_CRASHER=1", "EXIT_CODE=1")
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
+ cmd.Run() // Ignore error since we expect non-zero exit
+
+ output := stderr.String()
+ assert.Contains(t, output, "Error: fatal error")
+ assert.True(t, strings.HasSuffix(output, "\n"))
+ })
+}
+
+// Benchmarks for performance testing
+func BenchmarkPrintErr(b *testing.B) {
+ // Redirect stderr to /dev/null for benchmarking
+ oldStderr := os.Stderr
+ devNull, _ := os.Open(os.DevNull)
+ os.Stderr = devNull
+ defer func() {
+ os.Stderr = oldStderr
+ devNull.Close()
+ }()
+
+ b.Run("Simple message", func(b *testing.B) {
+ for range b.N {
+ PrintErr(text.FgRed, "Error message")
+ }
+ })
+
+ b.Run("Formatted message", func(b *testing.B) {
+ for range b.N {
+ PrintErr(text.FgRed, "Error: %s at line %d", "issue", 42)
+ }
+ })
+
+ b.Run("Different colors", func(b *testing.B) {
+ colors := []text.Color{text.FgRed, text.FgGreen, text.FgYellow}
+ for idx := range b.N {
+ PrintErr(colors[idx%len(colors)], "Message %d", idx)
+ }
+ })
+}
diff --git a/requests/client.go b/requests/client.go
deleted file mode 100644
index 4336a0c..0000000
--- a/requests/client.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package requests
-
-import (
- "context"
- "crypto/tls"
- "errors"
- "math/rand"
- "net/url"
- "time"
-
- "github.com/aykhans/dodo/utils"
- "github.com/valyala/fasthttp"
- "github.com/valyala/fasthttp/fasthttpproxy"
-)
-
-type ClientGeneratorFunc func() *fasthttp.HostClient
-
-// getClients initializes and returns a slice of fasthttp.HostClient based on the provided parameters.
-// It can either return clients with proxies or a single client without proxies.
-func getClients(
- _ context.Context,
- timeout time.Duration,
- proxies []url.URL,
- maxConns uint,
- URL url.URL,
- skipVerify bool,
-) []*fasthttp.HostClient {
- isTLS := URL.Scheme == "https"
-
- if proxiesLen := len(proxies); proxiesLen > 0 {
- clients := make([]*fasthttp.HostClient, 0, proxiesLen)
- addr := URL.Host
- if isTLS && URL.Port() == "" {
- addr += ":443"
- }
-
- for _, proxy := range proxies {
- dialFunc, err := getDialFunc(&proxy, timeout)
- if err != nil {
- continue
- }
-
- clients = append(clients, &fasthttp.HostClient{
- MaxConns: int(maxConns),
- IsTLS: isTLS,
- TLSConfig: &tls.Config{
- InsecureSkipVerify: skipVerify,
- },
- Addr: addr,
- Dial: dialFunc,
- MaxIdleConnDuration: timeout,
- MaxConnDuration: timeout,
- WriteTimeout: timeout,
- ReadTimeout: timeout,
- },
- )
- }
- return clients
- }
-
- client := &fasthttp.HostClient{
- MaxConns: int(maxConns),
- IsTLS: isTLS,
- TLSConfig: &tls.Config{
- InsecureSkipVerify: skipVerify,
- },
- Addr: URL.Host,
- MaxIdleConnDuration: timeout,
- MaxConnDuration: timeout,
- WriteTimeout: timeout,
- ReadTimeout: timeout,
- }
- return []*fasthttp.HostClient{client}
-}
-
-// getDialFunc returns the appropriate fasthttp.DialFunc based on the provided proxy URL scheme.
-// It supports SOCKS5 ('socks5' or 'socks5h') and HTTP ('http') proxy schemes.
-// For HTTP proxies, the timeout parameter determines connection timeouts.
-// Returns an error if the proxy scheme is unsupported.
-func getDialFunc(proxy *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
- var dialer fasthttp.DialFunc
-
- switch proxy.Scheme {
- case "socks5", "socks5h":
- dialer = fasthttpproxy.FasthttpSocksDialerDualStack(proxy.String())
- case "http":
- dialer = fasthttpproxy.FasthttpHTTPDialerDualStackTimeout(proxy.String(), timeout)
- default:
- return nil, errors.New("unsupported proxy scheme")
- }
-
- if dialer == nil {
- return nil, errors.New("internal error: proxy dialer is nil")
- }
-
- return dialer, nil
-}
-
-// getSharedClientFuncMultiple returns a ClientGeneratorFunc that cycles through a list of fasthttp.HostClient instances.
-// The function uses a local random number generator to determine the starting index and stop index for cycling through the clients.
-// The returned function isn't thread-safe and should be used in a single-threaded context.
-func getSharedClientFuncMultiple(clients []*fasthttp.HostClient, localRand *rand.Rand) ClientGeneratorFunc {
- return utils.RandomValueCycle(clients, localRand)
-}
-
-// getSharedClientFuncSingle returns a ClientGeneratorFunc that always returns the provided fasthttp.HostClient instance.
-// This can be useful for sharing a single client instance across multiple requests.
-func getSharedClientFuncSingle(client *fasthttp.HostClient) ClientGeneratorFunc {
- return func() *fasthttp.HostClient {
- return client
- }
-}
diff --git a/requests/helper.go b/requests/helper.go
deleted file mode 100644
index 47d4bfd..0000000
--- a/requests/helper.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package requests
-
-import (
- "context"
- "fmt"
- "sync"
- "time"
-
- "github.com/jedib0t/go-pretty/v6/progress"
-)
-
-// streamProgress streams the progress of a task to the console using a progress bar.
-// It listens for increments on the provided channel and updates the progress bar accordingly.
-//
-// The function will stop and mark the progress as errored if the context is cancelled.
-// It will also stop and mark the progress as done when the total number of increments is reached.
-func streamProgress(
- ctx context.Context,
- wg *sync.WaitGroup,
- total uint,
- message string,
- increase <-chan int64,
-) {
- defer wg.Done()
- pw := progress.NewWriter()
- pw.SetTrackerPosition(progress.PositionRight)
- pw.SetStyle(progress.StyleBlocks)
- pw.SetTrackerLength(40)
- pw.SetUpdateFrequency(time.Millisecond * 250)
- if total == 0 {
- pw.Style().Visibility.Percentage = false
- }
- go pw.Render()
- dodosTracker := progress.Tracker{
- Message: message,
- Total: int64(total),
- }
- pw.AppendTracker(&dodosTracker)
-
- for {
- select {
- case <-ctx.Done():
- if err := ctx.Err(); err == context.Canceled || err == context.DeadlineExceeded {
- dodosTracker.MarkAsDone()
- } else {
- dodosTracker.MarkAsErrored()
- }
- time.Sleep(time.Millisecond * 300)
- fmt.Printf("\r")
- return
-
- case value := <-increase:
- dodosTracker.Increment(value)
- }
- }
-}
diff --git a/requests/request.go b/requests/request.go
deleted file mode 100644
index acd1a6a..0000000
--- a/requests/request.go
+++ /dev/null
@@ -1,341 +0,0 @@
-package requests
-
-import (
- "bytes"
- "context"
- "math/rand"
- "net/url"
- "text/template"
- "time"
-
- "github.com/aykhans/dodo/config"
- "github.com/aykhans/dodo/types"
- "github.com/aykhans/dodo/utils"
- "github.com/valyala/fasthttp"
-)
-
-type RequestGeneratorFunc func() *fasthttp.Request
-
-// Request represents an HTTP request to be sent using the fasthttp client.
-// It isn't thread-safe and should be used by a single goroutine.
-type Request struct {
- getClient ClientGeneratorFunc
- getRequest RequestGeneratorFunc
-}
-
-type keyValueGenerator struct {
- key func() string
- value func() string
-}
-
-// Send sends the HTTP request using the fasthttp client with a specified timeout.
-// It returns the HTTP response or an error if the request fails or times out.
-func (r *Request) Send(ctx context.Context, timeout time.Duration) (*fasthttp.Response, error) {
- client := r.getClient()
- request := r.getRequest()
- defer fasthttp.ReleaseRequest(request)
-
- response := fasthttp.AcquireResponse()
- ch := make(chan error)
- go func() {
- err := client.DoTimeout(request, response, timeout)
- ch <- err
- }()
- select {
- case err := <-ch:
- if err != nil {
- fasthttp.ReleaseResponse(response)
- return nil, err
- }
- return response, nil
- case <-time.After(timeout):
- fasthttp.ReleaseResponse(response)
- return nil, types.ErrTimeout
- case <-ctx.Done():
- return nil, types.ErrInterrupt
- }
-}
-
-// newRequest creates a new Request instance based on the provided configuration and clients.
-// It initializes a random number generator using the current time and a unique identifier (uid).
-// Depending on the number of clients provided, it sets up a function to select the appropriate client.
-// It also sets up a function to generate the request based on the provided configuration.
-func newRequest(
- requestConfig config.RequestConfig,
- clients []*fasthttp.HostClient,
- uid int64,
-) *Request {
- localRand := rand.New(rand.NewSource(time.Now().UnixNano() + uid))
-
- clientsCount := len(clients)
- if clientsCount < 1 {
- panic("no clients")
- }
-
- getClient := ClientGeneratorFunc(nil)
- if clientsCount == 1 {
- getClient = getSharedClientFuncSingle(clients[0])
- } else {
- getClient = getSharedClientFuncMultiple(clients, localRand)
- }
-
- getRequest := getRequestGeneratorFunc(
- requestConfig.URL,
- requestConfig.Params,
- requestConfig.Headers,
- requestConfig.Cookies,
- requestConfig.Method,
- requestConfig.Body,
- localRand,
- )
-
- requests := &Request{
- getClient: getClient,
- getRequest: getRequest,
- }
-
- return requests
-}
-
-// getRequestGeneratorFunc returns a RequestGeneratorFunc which generates HTTP requests with the specified parameters.
-// The function uses a local random number generator to select bodies, headers, cookies, and parameters if multiple options are provided.
-func getRequestGeneratorFunc(
- URL url.URL,
- params types.Params,
- headers types.Headers,
- cookies types.Cookies,
- method string,
- bodies []string,
- localRand *rand.Rand,
-) RequestGeneratorFunc {
- getParams := getKeyValueGeneratorFunc(params, localRand)
- getHeaders := getKeyValueGeneratorFunc(headers, localRand)
- getCookies := getKeyValueGeneratorFunc(cookies, localRand)
- getBody := getBodyValueFunc(bodies, utils.NewFuncMapGenerator(localRand), localRand)
-
- return func() *fasthttp.Request {
- body, contentType := getBody()
- headers := getHeaders()
- if contentType != "" {
- headers = append(headers, types.KeyValue[string, string]{
- Key: "Content-Type",
- Value: contentType,
- })
- }
-
- return newFasthttpRequest(
- URL,
- getParams(),
- headers,
- getCookies(),
- method,
- body,
- )
- }
-}
-
-// newFasthttpRequest creates a new fasthttp.Request object with the provided parameters.
-// It sets the request URI, host header, headers, cookies, params, method, and body.
-func newFasthttpRequest(
- URL url.URL,
- params []types.KeyValue[string, string],
- headers []types.KeyValue[string, string],
- cookies []types.KeyValue[string, string],
- method string,
- body string,
-) *fasthttp.Request {
- request := fasthttp.AcquireRequest()
- request.SetRequestURI(URL.Path)
-
- // Set the host of the request to the host header
- // If the host header is not set, the request will fail
- // If there is host header in the headers, it will be overwritten
- request.Header.SetHost(URL.Host)
- setRequestParams(request, params)
- setRequestHeaders(request, headers)
- setRequestCookies(request, cookies)
- setRequestMethod(request, method)
- setRequestBody(request, body)
- if URL.Scheme == "https" {
- request.URI().SetScheme("https")
- }
-
- return request
-}
-
-// setRequestParams adds the query parameters of the given request based on the provided key-value pairs.
-func setRequestParams(req *fasthttp.Request, params []types.KeyValue[string, string]) {
- for _, param := range params {
- req.URI().QueryArgs().Add(param.Key, param.Value)
- }
-}
-
-// setRequestHeaders adds the headers of the given request with the provided key-value pairs.
-func setRequestHeaders(req *fasthttp.Request, headers []types.KeyValue[string, string]) {
- for _, header := range headers {
- req.Header.Add(header.Key, header.Value)
- }
-}
-
-// setRequestCookies adds the cookies of the given request with the provided key-value pairs.
-func setRequestCookies(req *fasthttp.Request, cookies []types.KeyValue[string, string]) {
- for _, cookie := range cookies {
- req.Header.Add("Cookie", cookie.Key+"="+cookie.Value)
- }
-}
-
-// setRequestMethod sets the HTTP request method for the given request.
-func setRequestMethod(req *fasthttp.Request, method string) {
- req.Header.SetMethod(method)
-}
-
-// setRequestBody sets the request body of the given fasthttp.Request object.
-// The body parameter is a string that will be converted to a byte slice and set as the request body.
-func setRequestBody(req *fasthttp.Request, body string) {
- req.SetBody([]byte(body))
-}
-
-// getKeyValueGeneratorFunc creates a function that generates key-value pairs for HTTP requests.
-// It takes a slice of key-value pairs where each key maps to a slice of possible values,
-// and a random number generator.
-//
-// If any key has multiple possible values, the function will randomly select one value for each
-// call (using the provided random number generator). If all keys have at most one value, the
-// function will always return the same set of key-value pairs for efficiency.
-func getKeyValueGeneratorFunc[
- T []types.KeyValue[string, string],
-](
- keyValueSlice []types.KeyValue[string, []string],
- localRand *rand.Rand,
-) func() T {
- keyValueGenerators := make([]keyValueGenerator, len(keyValueSlice))
-
- funcMap := *utils.NewFuncMapGenerator(localRand).GetFuncMap()
-
- for i, kv := range keyValueSlice {
- keyValueGenerators[i] = keyValueGenerator{
- key: getKeyFunc(kv.Key, funcMap),
- value: getValueFunc(kv.Value, funcMap, localRand),
- }
- }
-
- return func() T {
- keyValues := make(T, len(keyValueGenerators))
- for i, keyValue := range keyValueGenerators {
- keyValues[i] = types.KeyValue[string, string]{
- Key: keyValue.key(),
- Value: keyValue.value(),
- }
- }
- return keyValues
- }
-}
-
-// getKeyFunc creates a function that processes a key string through Go's template engine.
-// It takes a key string and a template.FuncMap containing the available template functions.
-//
-// The returned function, when called, will execute the template with the given key and return
-// the processed string result. If template parsing fails, the returned function will always
-// return an empty string.
-//
-// This enables dynamic generation of keys that can include template directives and functions.
-func getKeyFunc(key string, funcMap template.FuncMap) func() string {
- t, err := template.New("default").Funcs(funcMap).Parse(key)
- if err != nil {
- return func() string { return "" }
- }
-
- return func() string {
- var buf bytes.Buffer
- _ = t.Execute(&buf, nil)
- return buf.String()
- }
-}
-
-// getValueFunc creates a function that randomly selects and processes a value from a slice of strings
-// through Go's template engine.
-//
-// Parameters:
-// - values: A slice of string templates that can contain template directives
-// - funcMap: A template.FuncMap containing all available template functions
-// - localRand: A random number generator for consistent randomization
-//
-// The returned function, when called, will:
-// 1. Select a random template from the values slice
-// 2. Execute the selected template
-// 3. Return the processed string result
-//
-// If a selected template is nil (due to earlier parsing failure), the function will return an empty string.
-// This enables dynamic generation of values with randomized selection from multiple templates.
-func getValueFunc(
- values []string,
- funcMap template.FuncMap,
- localRand *rand.Rand,
-) func() string {
- templates := make([]*template.Template, len(values))
-
- for i, value := range values {
- t, err := template.New("default").Funcs(funcMap).Parse(value)
- if err != nil {
- templates[i] = nil
- }
- templates[i] = t
- }
-
- randomTemplateFunc := utils.RandomValueCycle(templates, localRand)
-
- return func() string {
- if tmpl := randomTemplateFunc(); tmpl == nil {
- return ""
- } else {
- var buf bytes.Buffer
- _ = tmpl.Execute(&buf, nil)
- return buf.String()
- }
- }
-}
-
-// getBodyValueFunc creates a function that randomly selects and processes a request body from a slice of templates.
-// It returns a closure that generates both the body content and the appropriate Content-Type header value.
-//
-// Parameters:
-// - values: A slice of string templates that can contain template directives for request bodies
-// - funcMapGenerator: Provides template functions and content type information
-// - localRand: A random number generator for consistent randomization
-//
-// The returned function, when called, will:
-// 1. Select a random body template from the values slice
-// 2. Execute the selected template with available template functions
-// 3. Return both the processed body string and the appropriate Content-Type header value
-//
-// If the selected template is nil (due to earlier parsing failure), the function will return
-// empty strings for both the body and Content-Type.
-//
-// This enables dynamic generation of request bodies with proper content type headers.
-func getBodyValueFunc(
- values []string,
- funcMapGenerator *utils.FuncMapGenerator,
- localRand *rand.Rand,
-) func() (string, string) {
- templates := make([]*template.Template, len(values))
-
- for i, value := range values {
- t, err := template.New("default").Funcs(*funcMapGenerator.GetFuncMap()).Parse(value)
- if err != nil {
- templates[i] = nil
- }
- templates[i] = t
- }
-
- randomTemplateFunc := utils.RandomValueCycle(templates, localRand)
-
- return func() (string, string) {
- if tmpl := randomTemplateFunc(); tmpl == nil {
- return "", ""
- } else {
- var buf bytes.Buffer
- _ = tmpl.Execute(&buf, nil)
- return buf.String(), funcMapGenerator.GetBodyDataHeader()
- }
- }
-}
diff --git a/requests/response.go b/requests/response.go
deleted file mode 100644
index be3ec76..0000000
--- a/requests/response.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package requests
-
-import (
- "os"
- "time"
-
- "github.com/aykhans/dodo/types"
- "github.com/aykhans/dodo/utils"
- "github.com/jedib0t/go-pretty/v6/table"
-)
-
-type Response struct {
- Response string
- Time time.Duration
-}
-
-type Responses []Response
-
-// Print prints the responses in a tabular format, including information such as
-// response count, minimum time, maximum time, average time, and latency percentiles.
-func (responses Responses) Print() {
- if len(responses) == 0 {
- return
- }
-
- mergedResponses := make(map[string]types.Durations)
-
- totalDurations := make(types.Durations, len(responses))
- var totalSum time.Duration
- totalCount := len(responses)
-
- for i, response := range responses {
- totalSum += response.Time
- totalDurations[i] = response.Time
-
- mergedResponses[response.Response] = append(
- mergedResponses[response.Response],
- response.Time,
- )
- }
-
- t := table.NewWriter()
- t.SetOutputMirror(os.Stdout)
- t.SetStyle(table.StyleLight)
- t.SetColumnConfigs([]table.ColumnConfig{
- {Number: 1, WidthMax: 40},
- })
- t.AppendHeader(table.Row{
- "Response",
- "Count",
- "Min",
- "Max",
- "Average",
- "P90",
- "P95",
- "P99",
- })
-
- var roundPrecision int64 = 4
- for key, durations := range mergedResponses {
- durations.Sort()
- durationsLen := len(durations)
- durationsLenAsFloat := float64(durationsLen - 1)
-
- t.AppendRow(table.Row{
- key,
- durationsLen,
- utils.DurationRoundBy(*durations.First(), roundPrecision),
- utils.DurationRoundBy(*durations.Last(), roundPrecision),
- utils.DurationRoundBy(durations.Avg(), roundPrecision),
- utils.DurationRoundBy(durations[int(0.90*durationsLenAsFloat)], roundPrecision),
- utils.DurationRoundBy(durations[int(0.95*durationsLenAsFloat)], roundPrecision),
- utils.DurationRoundBy(durations[int(0.99*durationsLenAsFloat)], roundPrecision),
- })
- t.AppendSeparator()
- }
-
- if len(mergedResponses) > 1 {
- totalDurations.Sort()
- allDurationsLenAsFloat := float64(len(totalDurations) - 1)
-
- t.AppendRow(table.Row{
- "Total",
- totalCount,
- utils.DurationRoundBy(totalDurations[0], roundPrecision),
- utils.DurationRoundBy(totalDurations[len(totalDurations)-1], roundPrecision),
- utils.DurationRoundBy(totalSum/time.Duration(totalCount), roundPrecision), // Average
- utils.DurationRoundBy(totalDurations[int(0.90*allDurationsLenAsFloat)], roundPrecision),
- utils.DurationRoundBy(totalDurations[int(0.95*allDurationsLenAsFloat)], roundPrecision),
- utils.DurationRoundBy(totalDurations[int(0.99*allDurationsLenAsFloat)], roundPrecision),
- })
- }
- t.Render()
-}
diff --git a/requests/run.go b/requests/run.go
deleted file mode 100644
index edd59d6..0000000
--- a/requests/run.go
+++ /dev/null
@@ -1,211 +0,0 @@
-package requests
-
-import (
- "context"
- "strconv"
- "sync"
- "time"
-
- "github.com/aykhans/dodo/config"
- "github.com/aykhans/dodo/types"
- "github.com/aykhans/dodo/utils"
- "github.com/valyala/fasthttp"
-)
-
-// Run executes the main logic for processing requests based on the provided configuration.
-// It initializes clients based on the request configuration and releases the dodos.
-// If the context is canceled and no responses are collected, it returns an interrupt error.
-//
-// Parameters:
-// - ctx: The context for managing request lifecycle and cancellation.
-// - requestConfig: The configuration for the request, including timeout, proxies, and other settings.
-func Run(ctx context.Context, requestConfig *config.RequestConfig) (Responses, error) {
- if requestConfig.Duration > 0 {
- var cancel context.CancelFunc
- ctx, cancel = context.WithTimeout(ctx, requestConfig.Duration)
- defer cancel()
- }
-
- clients := getClients(
- ctx,
- requestConfig.Timeout,
- requestConfig.Proxies,
- requestConfig.GetMaxConns(fasthttp.DefaultMaxConnsPerHost),
- requestConfig.URL,
- requestConfig.SkipVerify,
- )
- if clients == nil {
- return nil, types.ErrInterrupt
- }
-
- responses := releaseDodos(ctx, requestConfig, clients)
- if ctx.Err() != nil && len(responses) == 0 {
- return nil, types.ErrInterrupt
- }
-
- return responses, nil
-}
-
-// releaseDodos sends requests concurrently using multiple dodos (goroutines) and returns the aggregated responses.
-//
-// The function performs the following steps:
-// 1. Initializes wait groups and other necessary variables.
-// 2. Starts a goroutine to stream progress updates.
-// 3. Distributes the total request count among the dodos.
-// 4. Starts a goroutine for each dodo to send requests concurrently.
-// 5. Waits for all dodos to complete their requests.
-// 6. Cancels the progress streaming context and waits for the progress goroutine to finish.
-// 7. Flattens and returns the aggregated responses.
-func releaseDodos(
- ctx context.Context,
- requestConfig *config.RequestConfig,
- clients []*fasthttp.HostClient,
-) Responses {
- var (
- wg sync.WaitGroup
- streamWG sync.WaitGroup
- requestCountPerDodo uint
- dodosCount = requestConfig.GetValidDodosCountForRequests()
- responses = make([][]Response, dodosCount)
- increase = make(chan int64, requestConfig.RequestCount)
- )
-
- wg.Add(int(dodosCount))
- streamWG.Add(1)
- streamCtx, streamCtxCancel := context.WithCancel(ctx)
-
- go streamProgress(streamCtx, &streamWG, requestConfig.RequestCount, "Dodos Working🔥", increase)
-
- if requestConfig.RequestCount == 0 {
- for i := range dodosCount {
- go sendRequest(
- ctx,
- newRequest(*requestConfig, clients, int64(i)),
- requestConfig.Timeout,
- &responses[i],
- increase,
- &wg,
- )
- }
- } else {
- for i := range dodosCount {
- if i+1 == dodosCount {
- requestCountPerDodo = requestConfig.RequestCount - (i * requestConfig.RequestCount / dodosCount)
- } else {
- requestCountPerDodo = ((i + 1) * requestConfig.RequestCount / dodosCount) -
- (i * requestConfig.RequestCount / dodosCount)
- }
-
- go sendRequestByCount(
- ctx,
- newRequest(*requestConfig, clients, int64(i)),
- requestConfig.Timeout,
- requestCountPerDodo,
- &responses[i],
- increase,
- &wg,
- )
- }
- }
-
- wg.Wait()
- streamCtxCancel()
- streamWG.Wait()
- return utils.Flatten(responses)
-}
-
-// sendRequestByCount sends a specified number of HTTP requests concurrently with a given timeout.
-// It appends the responses to the provided responseData slice and sends the count of completed requests
-// to the increase channel. The function terminates early if the context is canceled or if a custom
-// interrupt error is encountered.
-func sendRequestByCount(
- ctx context.Context,
- request *Request,
- timeout time.Duration,
- requestCount uint,
- responseData *[]Response,
- increase chan<- int64,
- wg *sync.WaitGroup,
-) {
- defer wg.Done()
-
- for range requestCount {
- if ctx.Err() != nil {
- return
- }
-
- func() {
- startTime := time.Now()
- response, err := request.Send(ctx, timeout)
- completedTime := time.Since(startTime)
- if response != nil {
- defer fasthttp.ReleaseResponse(response)
- }
-
- if err != nil {
- if err == types.ErrInterrupt {
- return
- }
- *responseData = append(*responseData, Response{
- Response: err.Error(),
- Time: completedTime,
- })
- increase <- 1
- return
- }
-
- *responseData = append(*responseData, Response{
- Response: strconv.Itoa(response.StatusCode()),
- Time: completedTime,
- })
- increase <- 1
- }()
- }
-}
-
-// sendRequest continuously sends HTTP requests until the context is canceled.
-// It records the response status code or error message along with the response time,
-// and signals each completed request through the increase channel.
-func sendRequest(
- ctx context.Context,
- request *Request,
- timeout time.Duration,
- responseData *[]Response,
- increase chan<- int64,
- wg *sync.WaitGroup,
-) {
- defer wg.Done()
-
- for {
- if ctx.Err() != nil {
- return
- }
-
- func() {
- startTime := time.Now()
- response, err := request.Send(ctx, timeout)
- completedTime := time.Since(startTime)
- if response != nil {
- defer fasthttp.ReleaseResponse(response)
- }
-
- if err != nil {
- if err == types.ErrInterrupt {
- return
- }
- *responseData = append(*responseData, Response{
- Response: err.Error(),
- Time: completedTime,
- })
- increase <- 1
- return
- }
-
- *responseData = append(*responseData, Response{
- Response: strconv.Itoa(response.StatusCode()),
- Time: completedTime,
- })
- increase <- 1
- }()
- }
-}
diff --git a/types/body.go b/types/body.go
deleted file mode 100644
index 4329b87..0000000
--- a/types/body.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package types
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
-
- "github.com/jedib0t/go-pretty/v6/text"
-)
-
-type Body []string
-
-func (body Body) String() string {
- var buffer bytes.Buffer
- if len(body) == 0 {
- return buffer.String()
- }
-
- if len(body) == 1 {
- buffer.WriteString(body[0])
- return buffer.String()
- }
-
- buffer.WriteString(text.FgBlue.Sprint("Random") + "[\n")
-
- indent := " "
-
- displayLimit := 5
-
- for i, item := range body[:min(len(body), displayLimit)] {
- if i > 0 {
- buffer.WriteString(",\n")
- }
-
- buffer.WriteString(indent + item)
- }
-
- // Add remaining count if there are more items
- if remainingValues := len(body) - displayLimit; remainingValues > 0 {
- buffer.WriteString(",\n" + indent + text.FgGreen.Sprintf("+%d bodies", remainingValues))
- }
-
- buffer.WriteString("\n]")
- return buffer.String()
-}
-
-func (body *Body) UnmarshalJSON(b []byte) error {
- var data any
- if err := json.Unmarshal(b, &data); err != nil {
- return err
- }
-
- switch v := data.(type) {
- case string:
- *body = []string{v}
- case []any:
- var slice []string
- for _, item := range v {
- slice = append(slice, fmt.Sprintf("%v", item))
- }
- *body = slice
- default:
- return fmt.Errorf("invalid type for Body: %T (should be string or []string)", v)
- }
-
- return nil
-}
-
-func (body *Body) UnmarshalYAML(unmarshal func(any) error) error {
- var data any
- if err := unmarshal(&data); err != nil {
- return err
- }
-
- switch v := data.(type) {
- case string:
- *body = []string{v}
- case []any:
- var slice []string
- for _, item := range v {
- slice = append(slice, fmt.Sprintf("%v", item))
- }
- *body = slice
- default:
- return fmt.Errorf("invalid type for Body: %T (should be string or []string)", v)
- }
-
- return nil
-}
-
-func (body *Body) Set(value string) error {
- *body = append(*body, value)
- return nil
-}
diff --git a/types/config_file.go b/types/config_file.go
deleted file mode 100644
index f5f2a89..0000000
--- a/types/config_file.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package types
-
-import "strings"
-
-type FileLocationType int
-
-const (
- FileLocationTypeLocal FileLocationType = iota
- FileLocationTypeRemoteHTTP
-)
-
-type ConfigFile string
-
-func (configFile ConfigFile) String() string {
- return string(configFile)
-}
-
-func (configFile ConfigFile) LocationType() FileLocationType {
- if strings.HasPrefix(string(configFile), "http://") || strings.HasPrefix(string(configFile), "https://") {
- return FileLocationTypeRemoteHTTP
- }
- return FileLocationTypeLocal
-}
-
-func (configFile ConfigFile) Extension() string {
- i := strings.LastIndex(configFile.String(), ".")
- if i == -1 {
- return ""
- }
-
- return configFile.String()[i+1:]
-}
diff --git a/types/cookies.go b/types/cookies.go
deleted file mode 100644
index c7d182f..0000000
--- a/types/cookies.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package types
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "strings"
-
- "github.com/jedib0t/go-pretty/v6/text"
-)
-
-type Cookies []KeyValue[string, []string]
-
-func (cookies Cookies) String() string {
- var buffer bytes.Buffer
- if len(cookies) == 0 {
- return buffer.String()
- }
-
- indent := " "
-
- displayLimit := 3
-
- for i, item := range cookies[:min(len(cookies), displayLimit)] {
- if i > 0 {
- buffer.WriteString(",\n")
- }
-
- if len(item.Value) == 1 {
- buffer.WriteString(item.Key + ": " + item.Value[0])
- continue
- }
- buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n")
-
- for ii, v := range item.Value[:min(len(item.Value), displayLimit)] {
- if ii == len(item.Value)-1 {
- buffer.WriteString(indent + v + "\n")
- } else {
- buffer.WriteString(indent + v + ",\n")
- }
- }
-
- // Add remaining values count if needed
- if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 {
- buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n")
- }
-
- buffer.WriteString("]")
- }
-
- // Add remaining key-value pairs count if needed
- if remainingPairs := len(cookies) - displayLimit; remainingPairs > 0 {
- buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d cookies", remainingPairs))
- }
-
- return buffer.String()
-}
-
-func (cookies *Cookies) AppendByKey(key, value string) {
- if item := cookies.GetValue(key); item != nil {
- *item = append(*item, value)
- } else {
- *cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{value}})
- }
-}
-
-func (cookies Cookies) GetValue(key string) *[]string {
- for i := range cookies {
- if cookies[i].Key == key {
- return &cookies[i].Value
- }
- }
- return nil
-}
-
-func (cookies *Cookies) UnmarshalJSON(b []byte) error {
- var data []map[string]any
- if err := json.Unmarshal(b, &data); err != nil {
- return err
- }
-
- for _, item := range data {
- for key, value := range item {
- switch parsedValue := value.(type) {
- case string:
- *cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}})
- case []any:
- parsedStr := make([]string, len(parsedValue))
- for i, item := range parsedValue {
- parsedStr[i] = fmt.Sprintf("%v", item)
- }
- *cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: parsedStr})
- default:
- return fmt.Errorf("unsupported type for cookies expected string or []string, got %T", parsedValue)
- }
- }
- }
-
- return nil
-}
-
-func (cookies *Cookies) UnmarshalYAML(unmarshal func(any) error) error {
- var raw []map[string]any
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- for _, param := range raw {
- for key, value := range param {
- switch parsed := value.(type) {
- case string:
- *cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: []string{parsed}})
- case []any:
- var values []string
- for _, v := range parsed {
- if str, ok := v.(string); ok {
- values = append(values, str)
- }
- }
- *cookies = append(*cookies, KeyValue[string, []string]{Key: key, Value: values})
- }
- }
- }
- return nil
-}
-
-func (cookies *Cookies) Set(value string) error {
- parts := strings.SplitN(value, "=", 2)
- switch len(parts) {
- case 0:
- cookies.AppendByKey("", "")
- case 1:
- cookies.AppendByKey(parts[0], "")
- case 2:
- cookies.AppendByKey(parts[0], parts[1])
- }
-
- return nil
-}
diff --git a/types/duration.go b/types/duration.go
deleted file mode 100644
index 5b79612..0000000
--- a/types/duration.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package types
-
-import (
- "encoding/json"
- "errors"
- "time"
-)
-
-type Duration struct {
- time.Duration
-}
-
-func (duration *Duration) UnmarshalJSON(b []byte) error {
- var v any
- if err := json.Unmarshal(b, &v); err != nil {
- return err
- }
- switch value := v.(type) {
- case float64:
- duration.Duration = time.Duration(value)
- return nil
- case string:
- var err error
- duration.Duration, err = time.ParseDuration(value)
- if err != nil {
- return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
- }
- return nil
- default:
- return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
- }
-}
-
-func (duration Duration) MarshalJSON() ([]byte, error) {
- return json.Marshal(duration.String())
-}
-
-func (duration *Duration) UnmarshalYAML(unmarshal func(any) error) error {
- var v any
- if err := unmarshal(&v); err != nil {
- return err
- }
- switch value := v.(type) {
- case float64:
- duration.Duration = time.Duration(value)
- return nil
- case string:
- var err error
- duration.Duration, err = time.ParseDuration(value)
- if err != nil {
- return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
- }
- return nil
- default:
- return errors.New("Duration is invalid (e.g. 400ms, 1s, 5m, 1h)")
- }
-}
diff --git a/types/durations.go b/types/durations.go
deleted file mode 100644
index ee4970d..0000000
--- a/types/durations.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package types
-
-import (
- "slices"
- "sort"
- "time"
-)
-
-type Durations []time.Duration
-
-func (d Durations) Sort(ascending ...bool) {
- // If ascending is provided and is false, sort in descending order
- if len(ascending) > 0 && ascending[0] == false {
- sort.Slice(d, func(i, j int) bool {
- return d[i] > d[j]
- })
- } else { // Otherwise, sort in ascending order
- slices.Sort(d)
- }
-}
-
-func (d Durations) First() *time.Duration {
- return &d[0]
-}
-
-func (d Durations) Last() *time.Duration {
- return &d[len(d)-1]
-}
-
-func (d Durations) Sum() time.Duration {
- sum := time.Duration(0)
- for _, duration := range d {
- sum += duration
- }
- return sum
-}
-
-func (d Durations) Avg() time.Duration {
- return d.Sum() / time.Duration(len(d))
-}
diff --git a/types/errors.go b/types/errors.go
deleted file mode 100644
index aeb7172..0000000
--- a/types/errors.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package types
-
-import (
- "errors"
-)
-
-var (
- ErrInterrupt = errors.New("interrupted")
- ErrTimeout = errors.New("timeout")
-)
diff --git a/types/headers.go b/types/headers.go
deleted file mode 100644
index dad080f..0000000
--- a/types/headers.go
+++ /dev/null
@@ -1,156 +0,0 @@
-package types
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "strings"
-
- "github.com/jedib0t/go-pretty/v6/text"
-)
-
-type Headers []KeyValue[string, []string]
-
-func (headers Headers) String() string {
- var buffer bytes.Buffer
- if len(headers) == 0 {
- return buffer.String()
- }
-
- indent := " "
-
- displayLimit := 3
-
- for i, item := range headers[:min(len(headers), displayLimit)] {
- if i > 0 {
- buffer.WriteString(",\n")
- }
-
- if len(item.Value) == 1 {
- buffer.WriteString(item.Key + ": " + item.Value[0])
- continue
- }
- buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n")
-
- for ii, v := range item.Value[:min(len(item.Value), displayLimit)] {
- if ii == len(item.Value)-1 {
- buffer.WriteString(indent + v + "\n")
- } else {
- buffer.WriteString(indent + v + ",\n")
- }
- }
-
- // Add remaining values count if needed
- if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 {
- buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n")
- }
-
- buffer.WriteString("]")
- }
-
- // Add remaining key-value pairs count if needed
- if remainingPairs := len(headers) - displayLimit; remainingPairs > 0 {
- buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d headers", remainingPairs))
- }
-
- return buffer.String()
-}
-
-func (headers *Headers) AppendByKey(key, value string) {
- if item := headers.GetValue(key); item != nil {
- *item = append(*item, value)
- } else {
- *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{value}})
- }
-}
-
-func (headers Headers) GetValue(key string) *[]string {
- for i := range headers {
- if headers[i].Key == key {
- return &headers[i].Value
- }
- }
- return nil
-}
-
-func (headers Headers) Has(key string) bool {
- for i := range headers {
- if headers[i].Key == key {
- return true
- }
- }
- return false
-}
-
-func (headers *Headers) UnmarshalJSON(b []byte) error {
- var data []map[string]any
- if err := json.Unmarshal(b, &data); err != nil {
- return err
- }
-
- for _, item := range data {
- for key, value := range item {
- switch parsedValue := value.(type) {
- case string:
- *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}})
- case []any:
- parsedStr := make([]string, len(parsedValue))
- for i, item := range parsedValue {
- parsedStr[i] = fmt.Sprintf("%v", item)
- }
- *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: parsedStr})
- default:
- return fmt.Errorf("unsupported type for headers expected string or []string, got %T", parsedValue)
- }
- }
- }
-
- return nil
-}
-
-func (headers *Headers) UnmarshalYAML(unmarshal func(any) error) error {
- var raw []map[string]any
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- for _, param := range raw {
- for key, value := range param {
- switch parsed := value.(type) {
- case string:
- *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{parsed}})
- case []any:
- var values []string
- for _, v := range parsed {
- if str, ok := v.(string); ok {
- values = append(values, str)
- }
- }
- *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: values})
- }
- }
- }
- return nil
-}
-
-func (headers *Headers) Set(value string) error {
- parts := strings.SplitN(value, ":", 2)
- switch len(parts) {
- case 0:
- headers.AppendByKey("", "")
- case 1:
- headers.AppendByKey(parts[0], "")
- case 2:
- headers.AppendByKey(parts[0], parts[1])
- }
-
- return nil
-}
-
-func (headers *Headers) SetIfNotExists(key string, value string) bool {
- if headers.Has(key) {
- return false
- }
- *headers = append(*headers, KeyValue[string, []string]{Key: key, Value: []string{value}})
- return true
-}
diff --git a/types/params.go b/types/params.go
deleted file mode 100644
index 598876d..0000000
--- a/types/params.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package types
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "strings"
-
- "github.com/jedib0t/go-pretty/v6/text"
-)
-
-type Params []KeyValue[string, []string]
-
-func (params Params) String() string {
- var buffer bytes.Buffer
- if len(params) == 0 {
- return buffer.String()
- }
-
- indent := " "
-
- displayLimit := 3
-
- for i, item := range params[:min(len(params), displayLimit)] {
- if i > 0 {
- buffer.WriteString(",\n")
- }
-
- if len(item.Value) == 1 {
- buffer.WriteString(item.Key + ": " + item.Value[0])
- continue
- }
- buffer.WriteString(item.Key + ": " + text.FgBlue.Sprint("Random") + "[\n")
-
- for ii, v := range item.Value[:min(len(item.Value), displayLimit)] {
- if ii == len(item.Value)-1 {
- buffer.WriteString(indent + v + "\n")
- } else {
- buffer.WriteString(indent + v + ",\n")
- }
- }
-
- // Add remaining values count if needed
- if remainingValues := len(item.Value) - displayLimit; remainingValues > 0 {
- buffer.WriteString(indent + text.FgGreen.Sprintf("+%d values", remainingValues) + "\n")
- }
-
- buffer.WriteString("]")
- }
-
- // Add remaining key-value pairs count if needed
- if remainingPairs := len(params) - displayLimit; remainingPairs > 0 {
- buffer.WriteString(",\n" + text.FgGreen.Sprintf("+%d params", remainingPairs))
- }
-
- return buffer.String()
-}
-
-func (params *Params) AppendByKey(key, value string) {
- if item := params.GetValue(key); item != nil {
- *item = append(*item, value)
- } else {
- *params = append(*params, KeyValue[string, []string]{Key: key, Value: []string{value}})
- }
-}
-
-func (params Params) GetValue(key string) *[]string {
- for i := range params {
- if params[i].Key == key {
- return ¶ms[i].Value
- }
- }
- return nil
-}
-
-func (params *Params) UnmarshalJSON(b []byte) error {
- var data []map[string]any
- if err := json.Unmarshal(b, &data); err != nil {
- return err
- }
-
- for _, item := range data {
- for key, value := range item {
- switch parsedValue := value.(type) {
- case string:
- *params = append(*params, KeyValue[string, []string]{Key: key, Value: []string{parsedValue}})
- case []any:
- parsedStr := make([]string, len(parsedValue))
- for i, item := range parsedValue {
- parsedStr[i] = fmt.Sprintf("%v", item)
- }
- *params = append(*params, KeyValue[string, []string]{Key: key, Value: parsedStr})
- default:
- return fmt.Errorf("unsupported type for params expected string or []string, got %T", parsedValue)
- }
- }
- }
-
- return nil
-}
-
-func (params *Params) UnmarshalYAML(unmarshal func(any) error) error {
- var raw []map[string]any
- if err := unmarshal(&raw); err != nil {
- return err
- }
-
- for _, param := range raw {
- for key, value := range param {
- switch parsed := value.(type) {
- case string:
- *params = append(*params, KeyValue[string, []string]{Key: key, Value: []string{parsed}})
- case []any:
- var values []string
- for _, v := range parsed {
- if str, ok := v.(string); ok {
- values = append(values, str)
- }
- }
- *params = append(*params, KeyValue[string, []string]{Key: key, Value: values})
- }
- }
- }
- return nil
-}
-
-func (params *Params) Set(value string) error {
- parts := strings.SplitN(value, "=", 2)
- switch len(parts) {
- case 0:
- params.AppendByKey("", "")
- case 1:
- params.AppendByKey(parts[0], "")
- case 2:
- params.AppendByKey(parts[0], parts[1])
- }
-
- return nil
-}
diff --git a/types/proxies.go b/types/proxies.go
deleted file mode 100644
index 7d8323d..0000000
--- a/types/proxies.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package types
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "net/url"
-
- "github.com/jedib0t/go-pretty/v6/text"
-)
-
-type Proxies []url.URL
-
-func (proxies Proxies) String() string {
- var buffer bytes.Buffer
- if len(proxies) == 0 {
- return buffer.String()
- }
-
- if len(proxies) == 1 {
- buffer.WriteString(proxies[0].String())
- return buffer.String()
- }
-
- buffer.WriteString(text.FgBlue.Sprint("Random") + "[\n")
-
- indent := " "
-
- displayLimit := 5
-
- for i, item := range proxies[:min(len(proxies), displayLimit)] {
- if i > 0 {
- buffer.WriteString(",\n")
- }
-
- buffer.WriteString(indent + item.String())
- }
-
- // Add remaining count if there are more items
- if remainingValues := len(proxies) - displayLimit; remainingValues > 0 {
- buffer.WriteString(",\n" + indent + text.FgGreen.Sprintf("+%d proxies", remainingValues))
- }
-
- buffer.WriteString("\n]")
- return buffer.String()
-}
-
-func (proxies *Proxies) UnmarshalJSON(b []byte) error {
- var data any
- if err := json.Unmarshal(b, &data); err != nil {
- return err
- }
-
- switch v := data.(type) {
- case string:
- parsed, err := url.Parse(v)
- if err != nil {
- return err
- }
- *proxies = []url.URL{*parsed}
- case []any:
- var urls []url.URL
- for _, item := range v {
- url, err := url.Parse(item.(string))
- if err != nil {
- return err
- }
- urls = append(urls, *url)
- }
- *proxies = urls
- default:
- return fmt.Errorf("invalid type for Body: %T (should be URL or []URL)", v)
- }
-
- return nil
-}
-
-func (proxies *Proxies) UnmarshalYAML(unmarshal func(any) error) error {
- var data any
- if err := unmarshal(&data); err != nil {
- return err
- }
-
- switch v := data.(type) {
- case string:
- parsed, err := url.Parse(v)
- if err != nil {
- return err
- }
- *proxies = []url.URL{*parsed}
- case []any:
- var urls []url.URL
- for _, item := range v {
- url, err := url.Parse(item.(string))
- if err != nil {
- return err
- }
- urls = append(urls, *url)
- }
- *proxies = urls
- default:
- return fmt.Errorf("invalid type for Body: %T (should be URL or []URL)", v)
- }
-
- return nil
-}
-
-func (proxies *Proxies) Set(value string) error {
- parsedURL, err := url.Parse(value)
- if err != nil {
- return err
- }
-
- *proxies = append(*proxies, *parsedURL)
- return nil
-}
diff --git a/types/request_url.go b/types/request_url.go
deleted file mode 100644
index 4553ec4..0000000
--- a/types/request_url.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package types
-
-import (
- "encoding/json"
- "errors"
- "net/url"
-)
-
-type RequestURL struct {
- url.URL
-}
-
-func (requestURL *RequestURL) UnmarshalJSON(data []byte) error {
- var urlStr string
- if err := json.Unmarshal(data, &urlStr); err != nil {
- return err
- }
-
- parsedURL, err := url.Parse(urlStr)
- if err != nil {
- return errors.New("request URL is invalid")
- }
-
- requestURL.URL = *parsedURL
- return nil
-}
-
-func (requestURL *RequestURL) UnmarshalYAML(unmarshal func(any) error) error {
- var urlStr string
- if err := unmarshal(&urlStr); err != nil {
- return err
- }
-
- parsedURL, err := url.Parse(urlStr)
- if err != nil {
- return errors.New("request URL is invalid")
- }
-
- requestURL.URL = *parsedURL
- return nil
-}
-
-func (requestURL RequestURL) MarshalJSON() ([]byte, error) {
- return json.Marshal(requestURL.URL.String())
-}
-
-func (requestURL RequestURL) String() string {
- return requestURL.URL.String()
-}
-
-func (requestURL *RequestURL) Set(value string) error {
- parsedURL, err := url.Parse(value)
- if err != nil {
- return err
- }
-
- requestURL.URL = *parsedURL
- return nil
-}
diff --git a/types/timeout.go b/types/timeout.go
deleted file mode 100644
index 7d9e24a..0000000
--- a/types/timeout.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package types
-
-import (
- "encoding/json"
- "errors"
- "time"
-)
-
-type Timeout struct {
- time.Duration
-}
-
-func (timeout *Timeout) UnmarshalJSON(b []byte) error {
- var v any
- if err := json.Unmarshal(b, &v); err != nil {
- return err
- }
- switch value := v.(type) {
- case float64:
- timeout.Duration = time.Duration(value)
- return nil
- case string:
- var err error
- timeout.Duration, err = time.ParseDuration(value)
- if err != nil {
- return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
- }
- return nil
- default:
- return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
- }
-}
-
-func (timeout Timeout) MarshalJSON() ([]byte, error) {
- return json.Marshal(timeout.String())
-}
-
-func (timeout *Timeout) UnmarshalYAML(unmarshal func(any) error) error {
- var v any
- if err := unmarshal(&v); err != nil {
- return err
- }
- switch value := v.(type) {
- case float64:
- timeout.Duration = time.Duration(value)
- return nil
- case string:
- var err error
- timeout.Duration, err = time.ParseDuration(value)
- if err != nil {
- return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
- }
- return nil
- default:
- return errors.New("Timeout is invalid (e.g. 400ms, 1s, 5m, 1h)")
- }
-}
diff --git a/utils/compare.go b/utils/compare.go
deleted file mode 100644
index e49389f..0000000
--- a/utils/compare.go
+++ /dev/null
@@ -1,10 +0,0 @@
-package utils
-
-func IsNilOrZero[T comparable](value *T) bool {
- if value == nil {
- return true
- }
-
- var zero T
- return *value == zero
-}
diff --git a/utils/int.go b/utils/int.go
deleted file mode 100644
index 6faa348..0000000
--- a/utils/int.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package utils
-
-type Number interface {
- int | int8 | int16 | int32 | int64
-}
-
-func NumLen[T Number](n T) T {
- if n < 0 {
- n = -n
- }
- if n == 0 {
- return 1
- }
-
- var count T = 0
- for n > 0 {
- n /= 10
- count++
- }
- return count
-}
diff --git a/utils/print.go b/utils/print.go
deleted file mode 100644
index 489688a..0000000
--- a/utils/print.go
+++ /dev/null
@@ -1,24 +0,0 @@
-package utils
-
-import (
- "fmt"
- "os"
-
- "github.com/jedib0t/go-pretty/v6/text"
-)
-
-func PrintErr(err error) {
- fmt.Fprintln(os.Stderr, text.FgRed.Sprint(err.Error()))
-}
-
-func PrintErrAndExit(err error) {
- if err != nil {
- PrintErr(err)
- os.Exit(1)
- }
-}
-
-func PrintAndExit(message string) {
- fmt.Println(message)
- os.Exit(0)
-}
diff --git a/utils/slice.go b/utils/slice.go
deleted file mode 100644
index 3c6d636..0000000
--- a/utils/slice.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package utils
-
-import "math/rand"
-
-func Flatten[T any](nested [][]T) []T {
- flattened := make([]T, 0)
- for _, n := range nested {
- flattened = append(flattened, n...)
- }
- return flattened
-}
-
-// RandomValueCycle returns a function that cycles through the provided values in a pseudo-random order.
-// Each value in the input slice will be returned before any value is repeated.
-// If the input slice is empty, the returned function will always return the zero value of type T.
-// If the input slice contains only one element, that element is always returned.
-// This function is not thread-safe and should not be called concurrently.
-func RandomValueCycle[T any](values []T, localRand *rand.Rand) func() T {
- switch valuesLen := len(values); valuesLen {
- case 0:
- var zero T
- return func() T { return zero }
- case 1:
- return func() T { return values[0] }
- default:
- currentIndex := localRand.Intn(valuesLen)
- stopIndex := currentIndex
- return func() T {
- value := values[currentIndex]
- currentIndex++
- if currentIndex == valuesLen {
- currentIndex = 0
- }
- if currentIndex == stopIndex {
- currentIndex = localRand.Intn(valuesLen)
- stopIndex = currentIndex
- }
-
- return value
- }
- }
-}
diff --git a/utils/templates.go b/utils/templates.go
deleted file mode 100644
index ab54122..0000000
--- a/utils/templates.go
+++ /dev/null
@@ -1,479 +0,0 @@
-package utils
-
-import (
- "bytes"
- "math/rand"
- "mime/multipart"
- "strings"
- "text/template"
- "time"
-
- "github.com/brianvoe/gofakeit/v7"
-)
-
-type FuncMapGenerator struct {
- bodyDataHeader string
- localFaker *gofakeit.Faker
- funcMap *template.FuncMap
-}
-
-func NewFuncMapGenerator(localRand *rand.Rand) *FuncMapGenerator {
- f := &FuncMapGenerator{
- localFaker: gofakeit.NewFaker(localRand, false),
- }
- f.funcMap = f.newFuncMap()
-
- return f
-}
-
-func (g *FuncMapGenerator) GetBodyDataHeader() string {
- tempHeader := g.bodyDataHeader
- g.bodyDataHeader = ""
- return tempHeader
-}
-
-func (g *FuncMapGenerator) GetFuncMap() *template.FuncMap {
- return g.funcMap
-}
-
-// NewFuncMap creates a template.FuncMap populated with string manipulation functions
-// and data generation functions from gofakeit.
-//
-// It takes a random number generator that is used to initialize a localized faker
-// instance, ensuring that random data generation is deterministic within a request context.
-//
-// All functions are prefixed to avoid naming conflicts:
-// - String functions: "strings_*"
-// - Dict functions: "dict_*"
-// - Body functions: "body_*"
-// - Data generation functions: "fakeit_*"
-func (g *FuncMapGenerator) newFuncMap() *template.FuncMap {
- return &template.FuncMap{
- // Strings
- "strings_ToUpper": strings.ToUpper,
- "strings_ToLower": strings.ToLower,
- "strings_RemoveSpaces": func(s string) string { return strings.ReplaceAll(s, " ", "") },
- "strings_Replace": strings.Replace,
- "strings_ToDate": func(dateString string) time.Time {
- date, err := time.Parse("2006-01-02", dateString)
- if err != nil {
- return time.Now()
- }
- return date
- },
- "strings_First": func(s string, n int) string {
- if n >= len(s) {
- return s
- }
- return s[:n]
- },
- "strings_Last": func(s string, n int) string {
- if n >= len(s) {
- return s
- }
- return s[len(s)-n:]
- },
- "strings_Truncate": func(s string, n int) string {
- if n >= len(s) {
- return s
- }
- return s[:n] + "..."
- },
- "strings_TrimPrefix": strings.TrimPrefix,
- "strings_TrimSuffix": strings.TrimSuffix,
- "strings_Join": func(sep string, values ...string) string {
- return strings.Join(values, sep)
- },
-
- // Dict
- "dict_Str": func(values ...string) map[string]string {
- dict := make(map[string]string)
- for i := 0; i < len(values); i += 2 {
- if i+1 < len(values) {
- key := values[i]
- value := values[i+1]
- dict[key] = value
- }
- }
- return dict
- },
-
- // Slice
- "slice_Str": func(values ...string) []string { return values },
- "slice_Int": func(values ...int) []int { return values },
- "slice_Uint": func(values ...uint) []uint { return values },
-
- // Body
- "body_FormData": func(kv map[string]string) string {
- var data bytes.Buffer
- writer := multipart.NewWriter(&data)
-
- for k, v := range kv {
- _ = writer.WriteField(k, v)
- }
-
- _ = writer.Close()
- g.bodyDataHeader = writer.FormDataContentType()
-
- return data.String()
- },
-
- // FakeIt / Product
- "fakeit_ProductName": g.localFaker.ProductName,
- "fakeit_ProductDescription": g.localFaker.ProductDescription,
- "fakeit_ProductCategory": g.localFaker.ProductCategory,
- "fakeit_ProductFeature": g.localFaker.ProductFeature,
- "fakeit_ProductMaterial": g.localFaker.ProductMaterial,
- "fakeit_ProductUPC": g.localFaker.ProductUPC,
- "fakeit_ProductAudience": g.localFaker.ProductAudience,
- "fakeit_ProductDimension": g.localFaker.ProductDimension,
- "fakeit_ProductUseCase": g.localFaker.ProductUseCase,
- "fakeit_ProductBenefit": g.localFaker.ProductBenefit,
- "fakeit_ProductSuffix": g.localFaker.ProductSuffix,
-
- // FakeIt / Person
- "fakeit_Name": g.localFaker.Name,
- "fakeit_NamePrefix": g.localFaker.NamePrefix,
- "fakeit_NameSuffix": g.localFaker.NameSuffix,
- "fakeit_FirstName": g.localFaker.FirstName,
- "fakeit_MiddleName": g.localFaker.MiddleName,
- "fakeit_LastName": g.localFaker.LastName,
- "fakeit_Gender": g.localFaker.Gender,
- "fakeit_SSN": g.localFaker.SSN,
- "fakeit_Hobby": g.localFaker.Hobby,
- "fakeit_Email": g.localFaker.Email,
- "fakeit_Phone": g.localFaker.Phone,
- "fakeit_PhoneFormatted": g.localFaker.PhoneFormatted,
-
- // FakeIt / Auth
- "fakeit_Username": g.localFaker.Username,
- "fakeit_Password": g.localFaker.Password,
-
- // FakeIt / Address
- "fakeit_City": g.localFaker.City,
- "fakeit_Country": g.localFaker.Country,
- "fakeit_CountryAbr": g.localFaker.CountryAbr,
- "fakeit_State": g.localFaker.State,
- "fakeit_StateAbr": g.localFaker.StateAbr,
- "fakeit_Street": g.localFaker.Street,
- "fakeit_StreetName": g.localFaker.StreetName,
- "fakeit_StreetNumber": g.localFaker.StreetNumber,
- "fakeit_StreetPrefix": g.localFaker.StreetPrefix,
- "fakeit_StreetSuffix": g.localFaker.StreetSuffix,
- "fakeit_Zip": g.localFaker.Zip,
- "fakeit_Latitude": g.localFaker.Latitude,
- "fakeit_LatitudeInRange": func(min, max float64) float64 {
- value, err := g.localFaker.LatitudeInRange(min, max)
- if err != nil {
- var zero float64
- return zero
- }
- return value
- },
- "fakeit_Longitude": g.localFaker.Longitude,
- "fakeit_LongitudeInRange": func(min, max float64) float64 {
- value, err := g.localFaker.LongitudeInRange(min, max)
- if err != nil {
- var zero float64
- return zero
- }
- return value
- },
-
- // FakeIt / Game
- "fakeit_Gamertag": g.localFaker.Gamertag,
-
- // FakeIt / Beer
- "fakeit_BeerAlcohol": g.localFaker.BeerAlcohol,
- "fakeit_BeerBlg": g.localFaker.BeerBlg,
- "fakeit_BeerHop": g.localFaker.BeerHop,
- "fakeit_BeerIbu": g.localFaker.BeerIbu,
- "fakeit_BeerMalt": g.localFaker.BeerMalt,
- "fakeit_BeerName": g.localFaker.BeerName,
- "fakeit_BeerStyle": g.localFaker.BeerStyle,
- "fakeit_BeerYeast": g.localFaker.BeerYeast,
-
- // FakeIt / Car
- "fakeit_CarMaker": g.localFaker.CarMaker,
- "fakeit_CarModel": g.localFaker.CarModel,
- "fakeit_CarType": g.localFaker.CarType,
- "fakeit_CarFuelType": g.localFaker.CarFuelType,
- "fakeit_CarTransmissionType": g.localFaker.CarTransmissionType,
-
- // FakeIt / Words
- "fakeit_Noun": g.localFaker.Noun,
- "fakeit_NounCommon": g.localFaker.NounCommon,
- "fakeit_NounConcrete": g.localFaker.NounConcrete,
- "fakeit_NounAbstract": g.localFaker.NounAbstract,
- "fakeit_NounCollectivePeople": g.localFaker.NounCollectivePeople,
- "fakeit_NounCollectiveAnimal": g.localFaker.NounCollectiveAnimal,
- "fakeit_NounCollectiveThing": g.localFaker.NounCollectiveThing,
- "fakeit_NounCountable": g.localFaker.NounCountable,
- "fakeit_NounUncountable": g.localFaker.NounUncountable,
- "fakeit_Verb": g.localFaker.Verb,
- "fakeit_VerbAction": g.localFaker.VerbAction,
- "fakeit_VerbLinking": g.localFaker.VerbLinking,
- "fakeit_VerbHelping": g.localFaker.VerbHelping,
- "fakeit_Adverb": g.localFaker.Adverb,
- "fakeit_AdverbManner": g.localFaker.AdverbManner,
- "fakeit_AdverbDegree": g.localFaker.AdverbDegree,
- "fakeit_AdverbPlace": g.localFaker.AdverbPlace,
- "fakeit_AdverbTimeDefinite": g.localFaker.AdverbTimeDefinite,
- "fakeit_AdverbTimeIndefinite": g.localFaker.AdverbTimeIndefinite,
- "fakeit_AdverbFrequencyDefinite": g.localFaker.AdverbFrequencyDefinite,
- "fakeit_AdverbFrequencyIndefinite": g.localFaker.AdverbFrequencyIndefinite,
- "fakeit_Preposition": g.localFaker.Preposition,
- "fakeit_PrepositionSimple": g.localFaker.PrepositionSimple,
- "fakeit_PrepositionDouble": g.localFaker.PrepositionDouble,
- "fakeit_PrepositionCompound": g.localFaker.PrepositionCompound,
- "fakeit_Adjective": g.localFaker.Adjective,
- "fakeit_AdjectiveDescriptive": g.localFaker.AdjectiveDescriptive,
- "fakeit_AdjectiveQuantitative": g.localFaker.AdjectiveQuantitative,
- "fakeit_AdjectiveProper": g.localFaker.AdjectiveProper,
- "fakeit_AdjectiveDemonstrative": g.localFaker.AdjectiveDemonstrative,
- "fakeit_AdjectivePossessive": g.localFaker.AdjectivePossessive,
- "fakeit_AdjectiveInterrogative": g.localFaker.AdjectiveInterrogative,
- "fakeit_AdjectiveIndefinite": g.localFaker.AdjectiveIndefinite,
- "fakeit_Pronoun": g.localFaker.Pronoun,
- "fakeit_PronounPersonal": g.localFaker.PronounPersonal,
- "fakeit_PronounObject": g.localFaker.PronounObject,
- "fakeit_PronounPossessive": g.localFaker.PronounPossessive,
- "fakeit_PronounReflective": g.localFaker.PronounReflective,
- "fakeit_PronounDemonstrative": g.localFaker.PronounDemonstrative,
- "fakeit_PronounInterrogative": g.localFaker.PronounInterrogative,
- "fakeit_PronounRelative": g.localFaker.PronounRelative,
- "fakeit_Connective": g.localFaker.Connective,
- "fakeit_ConnectiveTime": g.localFaker.ConnectiveTime,
- "fakeit_ConnectiveComparative": g.localFaker.ConnectiveComparative,
- "fakeit_ConnectiveComplaint": g.localFaker.ConnectiveComplaint,
- "fakeit_ConnectiveListing": g.localFaker.ConnectiveListing,
- "fakeit_ConnectiveCasual": g.localFaker.ConnectiveCasual,
- "fakeit_ConnectiveExamplify": g.localFaker.ConnectiveExamplify,
- "fakeit_Word": g.localFaker.Word,
- "fakeit_Sentence": g.localFaker.Sentence,
- "fakeit_Paragraph": g.localFaker.Paragraph,
- "fakeit_LoremIpsumWord": g.localFaker.LoremIpsumWord,
- "fakeit_LoremIpsumSentence": g.localFaker.LoremIpsumSentence,
- "fakeit_LoremIpsumParagraph": g.localFaker.LoremIpsumParagraph,
- "fakeit_Question": g.localFaker.Question,
- "fakeit_Quote": g.localFaker.Quote,
- "fakeit_Phrase": g.localFaker.Phrase,
-
- // FakeIt / Foods
- "fakeit_Fruit": g.localFaker.Fruit,
- "fakeit_Vegetable": g.localFaker.Vegetable,
- "fakeit_Breakfast": g.localFaker.Breakfast,
- "fakeit_Lunch": g.localFaker.Lunch,
- "fakeit_Dinner": g.localFaker.Dinner,
- "fakeit_Snack": g.localFaker.Snack,
- "fakeit_Dessert": g.localFaker.Dessert,
-
- // FakeIt / Misc
- "fakeit_Bool": g.localFaker.Bool,
- "fakeit_UUID": g.localFaker.UUID,
- "fakeit_FlipACoin": g.localFaker.FlipACoin,
-
- // FakeIt / Colors
- "fakeit_Color": g.localFaker.Color,
- "fakeit_HexColor": g.localFaker.HexColor,
- "fakeit_RGBColor": g.localFaker.RGBColor,
- "fakeit_SafeColor": g.localFaker.SafeColor,
- "fakeit_NiceColors": g.localFaker.NiceColors,
-
- // FakeIt / Internet
- "fakeit_URL": g.localFaker.URL,
- "fakeit_DomainName": g.localFaker.DomainName,
- "fakeit_DomainSuffix": g.localFaker.DomainSuffix,
- "fakeit_IPv4Address": g.localFaker.IPv4Address,
- "fakeit_IPv6Address": g.localFaker.IPv6Address,
- "fakeit_MacAddress": g.localFaker.MacAddress,
- "fakeit_HTTPStatusCode": g.localFaker.HTTPStatusCode,
- "fakeit_HTTPStatusCodeSimple": g.localFaker.HTTPStatusCodeSimple,
- "fakeit_LogLevel": g.localFaker.LogLevel,
- "fakeit_HTTPMethod": g.localFaker.HTTPMethod,
- "fakeit_HTTPVersion": g.localFaker.HTTPVersion,
- "fakeit_UserAgent": g.localFaker.UserAgent,
- "fakeit_ChromeUserAgent": g.localFaker.ChromeUserAgent,
- "fakeit_FirefoxUserAgent": g.localFaker.FirefoxUserAgent,
- "fakeit_OperaUserAgent": g.localFaker.OperaUserAgent,
- "fakeit_SafariUserAgent": g.localFaker.SafariUserAgent,
-
- // FakeIt / HTML
- "fakeit_InputName": g.localFaker.InputName,
-
- // FakeIt / Date/Time
- "fakeit_Date": g.localFaker.Date,
- "fakeit_PastDate": g.localFaker.PastDate,
- "fakeit_FutureDate": g.localFaker.FutureDate,
- "fakeit_DateRange": g.localFaker.DateRange,
- "fakeit_NanoSecond": g.localFaker.NanoSecond,
- "fakeit_Second": g.localFaker.Second,
- "fakeit_Minute": g.localFaker.Minute,
- "fakeit_Hour": g.localFaker.Hour,
- "fakeit_Month": g.localFaker.Month,
- "fakeit_MonthString": g.localFaker.MonthString,
- "fakeit_Day": g.localFaker.Day,
- "fakeit_WeekDay": g.localFaker.WeekDay,
- "fakeit_Year": g.localFaker.Year,
- "fakeit_TimeZone": g.localFaker.TimeZone,
- "fakeit_TimeZoneAbv": g.localFaker.TimeZoneAbv,
- "fakeit_TimeZoneFull": g.localFaker.TimeZoneFull,
- "fakeit_TimeZoneOffset": g.localFaker.TimeZoneOffset,
- "fakeit_TimeZoneRegion": g.localFaker.TimeZoneRegion,
-
- // FakeIt / Payment
- "fakeit_Price": g.localFaker.Price,
- "fakeit_CreditCardCvv": g.localFaker.CreditCardCvv,
- "fakeit_CreditCardExp": g.localFaker.CreditCardExp,
- "fakeit_CreditCardNumber": g.localFaker.CreditCardNumber,
- "fakeit_CreditCardType": g.localFaker.CreditCardType,
- "fakeit_CurrencyLong": g.localFaker.CurrencyLong,
- "fakeit_CurrencyShort": g.localFaker.CurrencyShort,
- "fakeit_AchRouting": g.localFaker.AchRouting,
- "fakeit_AchAccount": g.localFaker.AchAccount,
- "fakeit_BitcoinAddress": g.localFaker.BitcoinAddress,
- "fakeit_BitcoinPrivateKey": g.localFaker.BitcoinPrivateKey,
-
- // FakeIt / Finance
- "fakeit_Cusip": g.localFaker.Cusip,
- "fakeit_Isin": g.localFaker.Isin,
-
- // FakeIt / Company
- "fakeit_BS": g.localFaker.BS,
- "fakeit_Blurb": g.localFaker.Blurb,
- "fakeit_BuzzWord": g.localFaker.BuzzWord,
- "fakeit_Company": g.localFaker.Company,
- "fakeit_CompanySuffix": g.localFaker.CompanySuffix,
- "fakeit_JobDescriptor": g.localFaker.JobDescriptor,
- "fakeit_JobLevel": g.localFaker.JobLevel,
- "fakeit_JobTitle": g.localFaker.JobTitle,
- "fakeit_Slogan": g.localFaker.Slogan,
-
- // FakeIt / Hacker
- "fakeit_HackerAbbreviation": g.localFaker.HackerAbbreviation,
- "fakeit_HackerAdjective": g.localFaker.HackerAdjective,
- "fakeit_HackerNoun": g.localFaker.HackerNoun,
- "fakeit_HackerPhrase": g.localFaker.HackerPhrase,
- "fakeit_HackerVerb": g.localFaker.HackerVerb,
-
- // FakeIt / Hipster
- "fakeit_HipsterWord": g.localFaker.HipsterWord,
- "fakeit_HipsterSentence": g.localFaker.HipsterSentence,
- "fakeit_HipsterParagraph": g.localFaker.HipsterParagraph,
-
- // FakeIt / App
- "fakeit_AppName": g.localFaker.AppName,
- "fakeit_AppVersion": g.localFaker.AppVersion,
- "fakeit_AppAuthor": g.localFaker.AppAuthor,
-
- // FakeIt / Animal
- "fakeit_PetName": g.localFaker.PetName,
- "fakeit_Animal": g.localFaker.Animal,
- "fakeit_AnimalType": g.localFaker.AnimalType,
- "fakeit_FarmAnimal": g.localFaker.FarmAnimal,
- "fakeit_Cat": g.localFaker.Cat,
- "fakeit_Dog": g.localFaker.Dog,
- "fakeit_Bird": g.localFaker.Bird,
-
- // FakeIt / Emoji
- "fakeit_Emoji": g.localFaker.Emoji,
- "fakeit_EmojiDescription": g.localFaker.EmojiDescription,
- "fakeit_EmojiCategory": g.localFaker.EmojiCategory,
- "fakeit_EmojiAlias": g.localFaker.EmojiAlias,
- "fakeit_EmojiTag": g.localFaker.EmojiTag,
-
- // FakeIt / Language
- "fakeit_Language": g.localFaker.Language,
- "fakeit_LanguageAbbreviation": g.localFaker.LanguageAbbreviation,
- "fakeit_ProgrammingLanguage": g.localFaker.ProgrammingLanguage,
-
- // FakeIt / Number
- "fakeit_Number": g.localFaker.Number,
- "fakeit_Int": g.localFaker.Int,
- "fakeit_IntN": g.localFaker.IntN,
- "fakeit_IntRange": g.localFaker.IntRange,
- "fakeit_RandomInt": g.localFaker.RandomInt,
- "fakeit_Int8": g.localFaker.Int8,
- "fakeit_Int16": g.localFaker.Int16,
- "fakeit_Int32": g.localFaker.Int32,
- "fakeit_Int64": g.localFaker.Int64,
- "fakeit_Uint": g.localFaker.Uint,
- "fakeit_UintN": g.localFaker.UintN,
- "fakeit_UintRange": g.localFaker.UintRange,
- "fakeit_RandomUint": g.localFaker.RandomUint,
- "fakeit_Uint8": g.localFaker.Uint8,
- "fakeit_Uint16": g.localFaker.Uint16,
- "fakeit_Uint32": g.localFaker.Uint32,
- "fakeit_Uint64": g.localFaker.Uint64,
- "fakeit_Float32": g.localFaker.Float32,
- "fakeit_Float32Range": g.localFaker.Float32Range,
- "fakeit_Float64": g.localFaker.Float64,
- "fakeit_Float64Range": g.localFaker.Float64Range,
- "fakeit_HexUint": g.localFaker.HexUint,
-
- // FakeIt / String
- "fakeit_Digit": g.localFaker.Digit,
- "fakeit_DigitN": g.localFaker.DigitN,
- "fakeit_Letter": g.localFaker.Letter,
- "fakeit_LetterN": g.localFaker.LetterN,
- "fakeit_LetterNN": func(min, max uint) string {
- return g.localFaker.LetterN(g.localFaker.UintRange(min, max))
- },
- "fakeit_Lexify": g.localFaker.Lexify,
- "fakeit_Numerify": g.localFaker.Numerify,
- "fakeit_RandomString": func(values ...string) string {
- return g.localFaker.RandomString(values)
- },
-
- // FakeIt / Celebrity
- "fakeit_CelebrityActor": g.localFaker.CelebrityActor,
- "fakeit_CelebrityBusiness": g.localFaker.CelebrityBusiness,
- "fakeit_CelebritySport": g.localFaker.CelebritySport,
-
- // FakeIt / Minecraft
- "fakeit_MinecraftOre": g.localFaker.MinecraftOre,
- "fakeit_MinecraftWood": g.localFaker.MinecraftWood,
- "fakeit_MinecraftArmorTier": g.localFaker.MinecraftArmorTier,
- "fakeit_MinecraftArmorPart": g.localFaker.MinecraftArmorPart,
- "fakeit_MinecraftWeapon": g.localFaker.MinecraftWeapon,
- "fakeit_MinecraftTool": g.localFaker.MinecraftTool,
- "fakeit_MinecraftDye": g.localFaker.MinecraftDye,
- "fakeit_MinecraftFood": g.localFaker.MinecraftFood,
- "fakeit_MinecraftAnimal": g.localFaker.MinecraftAnimal,
- "fakeit_MinecraftVillagerJob": g.localFaker.MinecraftVillagerJob,
- "fakeit_MinecraftVillagerStation": g.localFaker.MinecraftVillagerStation,
- "fakeit_MinecraftVillagerLevel": g.localFaker.MinecraftVillagerLevel,
- "fakeit_MinecraftMobPassive": g.localFaker.MinecraftMobPassive,
- "fakeit_MinecraftMobNeutral": g.localFaker.MinecraftMobNeutral,
- "fakeit_MinecraftMobHostile": g.localFaker.MinecraftMobHostile,
- "fakeit_MinecraftMobBoss": g.localFaker.MinecraftMobBoss,
- "fakeit_MinecraftBiome": g.localFaker.MinecraftBiome,
- "fakeit_MinecraftWeather": g.localFaker.MinecraftWeather,
-
- // FakeIt / Book
- "fakeit_BookTitle": g.localFaker.BookTitle,
- "fakeit_BookAuthor": g.localFaker.BookAuthor,
- "fakeit_BookGenre": g.localFaker.BookGenre,
-
- // FakeIt / Movie
- "fakeit_MovieName": g.localFaker.MovieName,
- "fakeit_MovieGenre": g.localFaker.MovieGenre,
-
- // FakeIt / Error
- "fakeit_Error": g.localFaker.Error,
- "fakeit_ErrorDatabase": g.localFaker.ErrorDatabase,
- "fakeit_ErrorGRPC": g.localFaker.ErrorGRPC,
- "fakeit_ErrorHTTP": g.localFaker.ErrorHTTP,
- "fakeit_ErrorHTTPClient": g.localFaker.ErrorHTTPClient,
- "fakeit_ErrorHTTPServer": g.localFaker.ErrorHTTPServer,
- "fakeit_ErrorRuntime": g.localFaker.ErrorRuntime,
-
- // FakeIt / School
- "fakeit_School": g.localFaker.School,
-
- // FakeIt / Song
- "fakeit_SongName": g.localFaker.SongName,
- "fakeit_SongArtist": g.localFaker.SongArtist,
- "fakeit_SongGenre": g.localFaker.SongGenre,
- }
-}
diff --git a/utils/time.go b/utils/time.go
deleted file mode 100644
index 9aff2fa..0000000
--- a/utils/time.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package utils
-
-import "time"
-
-func DurationRoundBy(duration time.Duration, n int64) time.Duration {
- if durationLen := NumLen(duration.Nanoseconds()); durationLen > n {
- roundNum := 1
- for range durationLen - n {
- roundNum *= 10
- }
- return duration.Round(time.Duration(roundNum))
- }
- return duration
-}