build(deps): bump github.com/olekukonko/tablewriter from 1.0.8 to 1.0.9

Bumps [github.com/olekukonko/tablewriter](https://github.com/olekukonko/tablewriter) from 1.0.8 to 1.0.9.
- [Commits](https://github.com/olekukonko/tablewriter/compare/v1.0.8...v1.0.9)

---
updated-dependencies:
- dependency-name: github.com/olekukonko/tablewriter
  dependency-version: 1.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
This commit is contained in:
dependabot[bot]
2025-08-26 06:40:11 +00:00
committed by GitHub
parent db583c4644
commit 5e6fc50e5e
23 changed files with 2385 additions and 383 deletions
+3 -3
View File
@@ -58,7 +58,7 @@ require (
github.com/nats-io/nats-server/v2 v2.11.7 github.com/nats-io/nats-server/v2 v2.11.7
github.com/nats-io/nats.go v1.44.0 github.com/nats-io/nats.go v1.44.0
github.com/oklog/run v1.2.0 github.com/oklog/run v1.2.0
github.com/olekukonko/tablewriter v1.0.8 github.com/olekukonko/tablewriter v1.0.9
github.com/onsi/ginkgo v1.16.5 github.com/onsi/ginkgo v1.16.5
github.com/onsi/ginkgo/v2 v2.25.1 github.com/onsi/ginkgo/v2 v2.25.1
github.com/onsi/gomega v1.38.0 github.com/onsi/gomega v1.38.0
@@ -265,8 +265,8 @@ require (
github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect github.com/nats-io/nuid v1.0.1 // indirect
github.com/nxadm/tail v1.4.8 // indirect github.com/nxadm/tail v1.4.8 // indirect
github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 // indirect github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.8 // indirect github.com/olekukonko/ll v0.0.9 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pablodz/inotifywaitgo v0.0.9 // indirect github.com/pablodz/inotifywaitgo v0.0.9 // indirect
+6 -6
View File
@@ -841,13 +841,13 @@ github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+
github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E= github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=
github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk= github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 h1:r3FaAI0NZK3hSmtTDrBVREhKULp8oUeqLT5Eyl2mSPo= github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.8 h1:sbGZ1Fx4QxJXEqL/6IG8GEFnYojUSQ45dJVwN2FH2fc= github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.8/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/olekukonko/tablewriter v1.0.8 h1:f6wJzHg4QUtJdvrVPKco4QTrAylgaU0+b9br/lJxEiQ= github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.8/go.mod h1:H428M+HzoUXC6JU2Abj9IT9ooRmdq9CxuDmKMtrOCMs= github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
+215 -8
View File
@@ -48,6 +48,11 @@ go get github.com/olekukonko/errors@latest
--- ---
> [!NOTE]
> ✓ added support for `errors.Errorf("user %w not found", errors.New("bob"))`
> ✓ added support for `sequential chain` execution
``
## Using the `errors` Package ## Using the `errors` Package
### Basic Error Creation ### Basic Error Creation
@@ -74,6 +79,7 @@ func main() {
#### Formatted Error #### Formatted Error
```go ```go
// main.go
package main package main
import ( import (
@@ -83,12 +89,21 @@ import (
func main() { func main() {
// Formatted error without stack trace // Formatted error without stack trace
err := errors.Newf("user %s not found", "bob") errNoWrap := errors.Newf("user %s not found", "bob")
fmt.Println(err) // Output: "user bob not found" fmt.Println(errNoWrap) // Output: "user bob not found"
// Standard formatted error, no fmt.Errorf needed // Standard formatted error, no fmt.Errorf needed (using own pkg)
stdErr := errors.Stdf("user %s not found", "bob") stdErrNoWrap := errors.Stdf("user %s not found", "bob")
fmt.Println(stdErr) // Output: "user bob not found" fmt.Println(stdErrNoWrap) // Output: "user bob not found"
// Added support for %w (compatible with fmt.Errorf output)
// errors.Errorf is alias of errors.Newf
errWrap := errors.Errorf("user %w not found", errors.New("bob"))
fmt.Println(errWrap) // Output: "user bob not found"
// Standard formatted error for comparison
stdErrWrap := fmt.Errorf("user %w not found", fmt.Errorf("bob"))
fmt.Println(stdErrWrap) // Output: "user bob not found"
} }
``` ```
@@ -243,7 +258,7 @@ func main() {
enhanced := errors.WithStack(err) enhanced := errors.WithStack(err)
fmt.Println("Error with stack:") fmt.Println("Error with stack:")
fmt.Println("Message:", enhanced.Error()) // Output: "basic error" fmt.Println("Message:", enhanced.Error()) // Output: "basic error"
fmt.Println("Stack:", enhanced.Stack()) // Output: e.g., ["main.go:15", ...] fmt.Println("Stack:", enhanced.Stack()) // Output: e.g., "main.go:15"
} }
``` ```
@@ -996,6 +1011,198 @@ func contains(substr string) func(error) bool {
return strings.Contains(err.Error(), substr) return strings.Contains(err.Error(), substr)
} }
} }
```
### Chain Execution
#### Sequential Task Processing
```go
package main
import (
"fmt"
"github.com/olekukonko/errors"
"time"
)
// validateOrder checks order input.
func validateOrder() error {
return nil // Simulate successful validation
}
// processKYC handles payment processing.
func processKYC() error {
return nil // Simulate successful validation
}
// processPayment handles payment processing.
func processPayment() error {
return errors.New("payment declined") // Simulate payment failure
}
// generateInvoice creates an invoice.
func generateInvoice() error {
return errors.New("invoicing unavailable") // Simulate invoicing issue
}
// sendNotification sends a confirmation.
func sendNotification() error {
return errors.New("notification failed") // Simulate notification failure
}
// processOrder simulates a multi-step order processing workflow.
func processOrder() error {
c := errors.NewChain()
// Validate order input
c.Step(validateOrder).Tag("validation")
// KYC Process
c.Step(validateOrder).Tag("validation")
// Process payment with retries
c.Step(processPayment).Tag("billing").Retry(3, 100*time.Millisecond)
// Generate invoice
c.Step(generateInvoice).Tag("invoicing")
// Send notification (optional)
c.Step(sendNotification).Tag("notification").Optional()
return c.Run()
}
func main() {
if err := processOrder(); err != nil {
// Print error to stderr and exit
errors.Inspect(err)
}
fmt.Println("Order processed successfully")
}
```
#### Sequential Task Processing 2
```go
package main
import (
"fmt"
"os"
"github.com/olekukonko/errors"
)
// validate simulates a validation check that fails.
func validate(name string) error {
return errors.Newf("validation for %s failed", name)
}
// validateOrder checks order input.
func validateOrder() error {
return nil // Simulate successful validation
}
// verifyKYC handles Know Your Customer verification.
func verifyKYC(name string) error {
return validate(name) // Simulate KYC validation failure
}
// processPayment handles payment processing.
func processPayment() error {
return nil // Simulate successful payment
}
// processOrder coordinates the order processing workflow.
func processOrder() error {
chain := errors.NewChain().
Step(validateOrder). // Step 1: Validate order
Call(verifyKYC, "john"). // Step 2: Verify customer
Step(processPayment) // Step 3: Process payment
if err := chain.Run(); err != nil {
return errors.Errorf("processing order: %w", err)
}
return nil
}
func main() {
if err := processOrder(); err != nil {
// Print the full error chain to stderr
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
// Output
// ERROR: processing order: validation for john failed
// For debugging, you could print the stack trace:
// errors.Inspect(err)
os.Exit(1)
}
fmt.Println("order processed successfully")
}
```
#### Retry with Timeout
```go
package main
import (
"context"
"fmt"
"github.com/olekukonko/errors"
"time"
)
func main() {
c := errors.NewChain(
errors.ChainWithTimeout(1*time.Second),
).
Step(func() error {
time.Sleep(2 * time.Second)
return errors.New("fetch failed")
}).
Tag("api").
Retry(3, 200*time.Millisecond)
err := c.Run()
if err != nil {
var deadlineErr error
if errors.As(err, &deadlineErr) && deadlineErr == context.DeadlineExceeded {
fmt.Println("Fetch timed out")
} else {
fmt.Printf("Fetch failed: %v\n", err)
}
return
}
fmt.Println("Fetch succeeded")
}
```
#### Collecting All Errors
```go
package main
import (
"fmt"
"github.com/olekukonko/errors"
)
func main() {
c := errors.NewChain(
errors.ChainWithMaxErrors(2),
).
Step(func() error { return errors.New("task 1 failed") }).Tag("task1").
Step(func() error { return nil }).Tag("task2").
Step(func() error { return errors.New("task 3 failed") }).Tag("task3")
err := c.RunAll()
if err != nil {
errors.Inspect(err)
return
}
fmt.Println("All tasks completed successfully")
}
``` ```
@@ -1326,7 +1533,7 @@ func main() {
## FAQ ## FAQ
- **When to use `Copy()`?** - **When to use `Copy()`?**
- Use `Copy()` to create a modifiable duplicate of an error without altering the original. - Use ` SOCIALCopy()` to create a modifiable duplicate of an error without altering the original.
- **When to use `Free()`?** - **When to use `Free()`?**
- Use in performance-critical loops; otherwise, autofree handles it (Go 1.24+). - Use in performance-critical loops; otherwise, autofree handles it (Go 1.24+).
@@ -1355,4 +1562,4 @@ func main() {
- Fork, branch, commit, and PR—see [CONTRIBUTING.md](#). - Fork, branch, commit, and PR—see [CONTRIBUTING.md](#).
## License ## License
MIT License - See [LICENSE](LICENSE). MIT License - See [LICENSE](LICENSE).
+610
View File
@@ -0,0 +1,610 @@
package errors
import (
"context"
"fmt"
"log/slog" // Standard structured logging package
"reflect"
"strings"
"time"
)
// Chain executes functions sequentially with enhanced error handling.
// Logging is optional and configured via a slog.Handler.
type Chain struct {
steps []chainStep // List of steps to execute
errors []error // Accumulated errors during execution
config chainConfig // Chain-wide configuration
lastStep *chainStep // Pointer to the last added step for configuration
logHandler slog.Handler // Optional logging handler (nil means no logging)
cancel context.CancelFunc // Function to cancel the context
}
// chainStep represents a single step in the chain.
type chainStep struct {
execute func() error // Function to execute for this step
optional bool // If true, errors don't stop the chain
config stepConfig // Step-specific configuration
}
// chainConfig holds chain-wide settings.
type chainConfig struct {
timeout time.Duration // Maximum duration for the entire chain
maxErrors int // Maximum number of errors before stopping (-1 for unlimited)
autoWrap bool // Whether to automatically wrap errors with additional context
}
// stepConfig holds configuration for an individual step.
type stepConfig struct {
context map[string]interface{} // Arbitrary key-value pairs for context
category ErrorCategory // Category for error classification
code int // Numeric error code
retry *Retry // Retry policy for the step
logOnFail bool // Whether to log errors automatically
metricsLabel string // Label for metrics (not used in this code)
logAttrs []slog.Attr // Additional attributes for logging
}
// ChainOption defines a function that configures a Chain.
type ChainOption func(*Chain)
// NewChain creates a new Chain with the given options.
// Logging is disabled by default (logHandler is nil).
func NewChain(opts ...ChainOption) *Chain {
c := &Chain{
config: chainConfig{
autoWrap: true, // Enable error wrapping by default
maxErrors: -1, // No limit on errors by default
},
// logHandler is nil, meaning no logging unless explicitly configured
}
// Apply each configuration option
for _, opt := range opts {
opt(c)
}
return c
}
// ChainWithLogHandler sets a custom slog.Handler for logging.
// If handler is nil, logging is effectively disabled.
func ChainWithLogHandler(handler slog.Handler) ChainOption {
return func(c *Chain) {
c.logHandler = handler
}
}
// ChainWithTimeout sets a timeout for the entire chain.
func ChainWithTimeout(d time.Duration) ChainOption {
return func(c *Chain) {
c.config.timeout = d
}
}
// ChainWithMaxErrors sets the maximum number of errors allowed.
// A value <= 0 means no limit.
func ChainWithMaxErrors(max int) ChainOption {
return func(c *Chain) {
if max <= 0 {
c.config.maxErrors = -1 // No limit
} else {
c.config.maxErrors = max
}
}
}
// ChainWithAutoWrap enables or disables automatic error wrapping.
func ChainWithAutoWrap(auto bool) ChainOption {
return func(c *Chain) {
c.config.autoWrap = auto
}
}
// Step adds a new step to the chain with the provided function.
// The function must return an error or nil.
func (c *Chain) Step(fn func() error) *Chain {
if fn == nil {
// Panic to enforce valid input
panic("Chain.Step: provided function cannot be nil")
}
// Create a new step with default configuration
step := chainStep{execute: fn, config: stepConfig{}}
c.steps = append(c.steps, step)
// Update lastStep to point to the newly added step
c.lastStep = &c.steps[len(c.steps)-1]
return c
}
// Call adds a step by wrapping a function with arguments.
// It uses reflection to validate and invoke the function.
func (c *Chain) Call(fn interface{}, args ...interface{}) *Chain {
// Wrap the function and arguments into an executable step
wrappedFn, err := c.wrapCallable(fn, args...)
if err != nil {
// Panic on setup errors to catch them early
panic(fmt.Sprintf("Chain.Call setup error: %v", err))
}
// Add the wrapped function as a step
step := chainStep{execute: wrappedFn, config: stepConfig{}}
c.steps = append(c.steps, step)
c.lastStep = &c.steps[len(c.steps)-1]
return c
}
// Optional marks the last step as optional.
// Optional steps don't stop the chain on error.
func (c *Chain) Optional() *Chain {
if c.lastStep == nil {
// Panic if no step exists to mark as optional
panic("Chain.Optional: must call Step() or Call() before Optional()")
}
c.lastStep.optional = true
return c
}
// WithLog adds logging attributes to the last step.
func (c *Chain) WithLog(attrs ...slog.Attr) *Chain {
if c.lastStep == nil {
// Panic if no step exists to configure
panic("Chain.WithLog: must call Step() or Call() before WithLog()")
}
// Append attributes to the step's logging configuration
c.lastStep.config.logAttrs = append(c.lastStep.config.logAttrs, attrs...)
return c
}
// Timeout sets a timeout for the entire chain.
func (c *Chain) Timeout(d time.Duration) *Chain {
c.config.timeout = d
return c
}
// MaxErrors sets the maximum number of errors allowed.
func (c *Chain) MaxErrors(max int) *Chain {
if max <= 0 {
c.config.maxErrors = -1 // No limit
} else {
c.config.maxErrors = max
}
return c
}
// With adds a key-value pair to the last step's context.
func (c *Chain) With(key string, value interface{}) *Chain {
if c.lastStep == nil {
// Panic if no step exists to configure
panic("Chain.With: must call Step() or Call() before With()")
}
// Initialize context map if nil
if c.lastStep.config.context == nil {
c.lastStep.config.context = make(map[string]interface{})
}
// Add the key-value pair
c.lastStep.config.context[key] = value
return c
}
// Tag sets an error category for the last step.
func (c *Chain) Tag(category ErrorCategory) *Chain {
if c.lastStep == nil {
// Panic if no step exists to configure
panic("Chain.Tag: must call Step() or Call() before Tag()")
}
c.lastStep.config.category = category
return c
}
// Code sets a numeric error code for the last step.
func (c *Chain) Code(code int) *Chain {
if c.lastStep == nil {
// Panic if no step exists to configure
panic("Chain.Code: must call Step() or Call() before Code()")
}
c.lastStep.config.code = code
return c
}
// Retry configures retry behavior for the last step.
// Retry configures retry behavior for the last step.
func (c *Chain) Retry(maxAttempts int, delay time.Duration, opts ...RetryOption) *Chain {
if c.lastStep == nil {
panic("Chain.Retry: must call Step() or Call() before Retry()")
}
if maxAttempts < 1 {
maxAttempts = 1
}
// Define default retry options
retryOpts := []RetryOption{
WithMaxAttempts(maxAttempts),
WithDelay(delay),
WithRetryIf(func(err error) bool { return IsRetryable(err) }),
}
// Add logging for retry attempts if a handler is configured
if c.logHandler != nil {
step := c.lastStep
retryOpts = append(retryOpts, WithOnRetry(func(attempt int, err error) {
// Prepare logging attributes
logAttrs := []slog.Attr{
slog.Int("attempt", attempt),
slog.Int("max_attempts", maxAttempts),
}
// Enhance the error with step context
enhancedErr := c.enhanceError(err, step)
// Log the retry attempt
c.logError(enhancedErr, fmt.Sprintf("Retrying step (attempt %d/%d)", attempt, maxAttempts), step.config, logAttrs...)
}))
}
// Append any additional retry options
retryOpts = append(retryOpts, opts...)
// Create and assign the retry configuration
c.lastStep.config.retry = NewRetry(retryOpts...)
return c
}
// LogOnFail enables automatic logging of errors for the last step.
func (c *Chain) LogOnFail() *Chain {
if c.lastStep == nil {
// Panic if no step exists to configure
panic("Chain.LogOnFail: must call Step() or Call() before LogOnFail()")
}
c.lastStep.config.logOnFail = true
return c
}
// Run executes the chain, stopping on the first non-optional error.
// It returns the first error encountered or nil if all steps succeed.
func (c *Chain) Run() error {
// Create a context with timeout or cancellation
ctx, cancel := c.getContextAndCancel()
defer cancel()
c.cancel = cancel
// Clear any previous errors
c.errors = c.errors[:0]
// Execute each step in sequence
for i := range c.steps {
step := &c.steps[i]
// Check if the context has been canceled
select {
case <-ctx.Done():
err := ctx.Err()
// Enhance the error with step context
enhancedErr := c.enhanceError(err, step)
c.errors = append(c.errors, enhancedErr)
// Log the context error
c.logError(enhancedErr, "Chain stopped due to context error before step", step.config)
return enhancedErr
default:
}
// Execute the step
err := c.executeStep(ctx, step)
if err != nil {
// Enhance the error with step context
enhancedErr := c.enhanceError(err, step)
c.errors = append(c.errors, enhancedErr)
// Log the error if required
if step.config.logOnFail || !step.optional {
logMsg := "Chain stopped due to error in step"
if step.optional {
logMsg = "Optional step failed"
}
c.logError(enhancedErr, logMsg, step.config)
}
// Stop execution if the step is not optional
if !step.optional {
return enhancedErr
}
}
}
// Return nil if all steps completed successfully
return nil
}
// RunAll executes all steps, collecting errors without stopping.
// It returns a MultiError containing all errors or nil if none occurred.
func (c *Chain) RunAll() error {
ctx, cancel := c.getContextAndCancel()
defer cancel()
c.cancel = cancel
c.errors = c.errors[:0]
multi := NewMultiError()
for i := range c.steps {
step := &c.steps[i]
select {
case <-ctx.Done():
err := ctx.Err()
enhancedErr := c.enhanceError(err, step)
c.errors = append(c.errors, enhancedErr)
multi.Add(enhancedErr)
c.logError(enhancedErr, "Chain stopped due to context error before step (RunAll)", step.config)
goto endRunAll
default:
}
err := c.executeStep(ctx, step)
if err != nil {
enhancedErr := c.enhanceError(err, step)
c.errors = append(c.errors, enhancedErr)
multi.Add(enhancedErr)
if step.config.logOnFail && c.logHandler != nil {
c.logError(enhancedErr, "Step failed during RunAll", step.config)
}
if c.config.maxErrors > 0 && multi.Count() >= c.config.maxErrors {
if c.logHandler != nil {
// Create a logger to log the max errors condition
logger := slog.New(c.logHandler)
logger.LogAttrs(
context.Background(),
slog.LevelError,
fmt.Sprintf("Stopping RunAll after reaching max errors (%d)", c.config.maxErrors),
slog.Int("max_errors", c.config.maxErrors),
)
}
goto endRunAll
}
}
}
endRunAll:
return multi.Single()
}
// Errors returns a copy of the collected errors.
func (c *Chain) Errors() []error {
if len(c.errors) == 0 {
return nil
}
// Create a copy to prevent external modification
errs := make([]error, len(c.errors))
copy(errs, c.errors)
return errs
}
// Len returns the number of steps in the chain.
func (c *Chain) Len() int {
return len(c.steps)
}
// HasErrors checks if any errors were collected.
func (c *Chain) HasErrors() bool {
return len(c.errors) > 0
}
// LastError returns the most recent error or nil if none exist.
func (c *Chain) LastError() error {
if len(c.errors) > 0 {
return c.errors[len(c.errors)-1]
}
return nil
}
// Reset clears the chain's steps, errors, and context.
func (c *Chain) Reset() {
if c.cancel != nil {
// Cancel any active context
c.cancel()
c.cancel = nil
}
// Clear steps and errors
c.steps = c.steps[:0]
c.errors = c.errors[:0]
c.lastStep = nil
}
// Unwrap returns the collected errors (alias for Errors).
func (c *Chain) Unwrap() []error {
return c.errors
}
// getContextAndCancel creates a context based on the chain's timeout.
// It returns a context and its cancellation function.
func (c *Chain) getContextAndCancel() (context.Context, context.CancelFunc) {
parentCtx := context.Background()
if c.config.timeout > 0 {
// Create a context with a timeout
return context.WithTimeout(parentCtx, c.config.timeout)
}
// Create a cancellable context
return context.WithCancel(parentCtx)
}
// logError logs an error with step-specific context and attributes.
// It only logs if a handler is configured and the error is non-nil.
func (c *Chain) logError(err error, msg string, config stepConfig, additionalAttrs ...slog.Attr) {
// Skip logging if no handler is set or error is nil
if c == nil || c.logHandler == nil || err == nil {
return
}
// Create a logger on demand using the configured handler
logger := slog.New(c.logHandler)
// Initialize attributes with error and timestamp
allAttrs := make([]slog.Attr, 0, 5+len(config.logAttrs)+len(additionalAttrs))
allAttrs = append(allAttrs, slog.Any("error", err))
allAttrs = append(allAttrs, slog.Time("timestamp", time.Now()))
// Add step-specific metadata
if config.category != "" {
allAttrs = append(allAttrs, slog.String("category", string(config.category)))
}
if config.code != 0 {
allAttrs = append(allAttrs, slog.Int("code", config.code))
}
for k, v := range config.context {
allAttrs = append(allAttrs, slog.Any(k, v))
}
allAttrs = append(allAttrs, config.logAttrs...)
allAttrs = append(allAttrs, additionalAttrs...)
// Add stack trace and error name if the error is of type *Error
if e, ok := err.(*Error); ok {
if stack := e.Stack(); len(stack) > 0 {
// Format stack trace, truncating if too long
stackStr := "\n\t" + strings.Join(stack, "\n\t")
if len(stackStr) > 1000 {
stackStr = stackStr[:1000] + "..."
}
allAttrs = append(allAttrs, slog.String("stacktrace", stackStr))
}
if name := e.Name(); name != "" {
allAttrs = append(allAttrs, slog.String("error_name", name))
}
}
// Log the error at ERROR level with all attributes
// Use a defer to catch any panics during logging
defer func() {
if r := recover(); r != nil {
// Print to stdout to avoid infinite recursion
fmt.Printf("ERROR: Recovered from panic during logging: %v\nAttributes: %v\n", r, allAttrs)
}
}()
logger.LogAttrs(context.Background(), slog.LevelError, msg, allAttrs...)
}
// wrapCallable wraps a function and its arguments into an executable step.
// It uses reflection to validate the function and arguments.
func (c *Chain) wrapCallable(fn interface{}, args ...interface{}) (func() error, error) {
val := reflect.ValueOf(fn)
typ := val.Type()
// Ensure the provided value is a function
if typ.Kind() != reflect.Func {
return nil, fmt.Errorf("provided 'fn' is not a function (got %T)", fn)
}
// Check if the number of arguments matches the function's signature
if typ.NumIn() != len(args) {
return nil, fmt.Errorf("function expects %d arguments, but %d were provided", typ.NumIn(), len(args))
}
// Prepare argument values
argVals := make([]reflect.Value, len(args))
errorType := reflect.TypeOf((*error)(nil)).Elem()
for i, arg := range args {
expectedType := typ.In(i)
var providedVal reflect.Value
if arg != nil {
providedVal = reflect.ValueOf(arg)
// Check if the argument type is assignable to the expected type
if !providedVal.Type().AssignableTo(expectedType) {
// Special case for error interfaces
if expectedType.Kind() == reflect.Interface && expectedType.Implements(errorType) && providedVal.Type().Implements(errorType) {
// Allow error interface
} else {
return nil, fmt.Errorf("argument %d type mismatch: expected %s, got %s", i, expectedType, providedVal.Type())
}
}
} else {
// Handle nil arguments for nullable types
switch expectedType.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
providedVal = reflect.Zero(expectedType)
default:
return nil, fmt.Errorf("argument %d is nil, but expected non-nillable type %s", i, expectedType)
}
}
argVals[i] = providedVal
}
// Validate the function's return type
if typ.NumOut() > 1 || (typ.NumOut() == 1 && !typ.Out(0).Implements(errorType)) {
return nil, fmt.Errorf("function must return either no values or a single error (got %d return values)", typ.NumOut())
}
// Return a wrapped function that calls the original with the provided arguments
return func() error {
results := val.Call(argVals)
if len(results) == 1 && results[0].Interface() != nil {
return results[0].Interface().(error)
}
return nil
}, nil
}
// executeStep runs a single step, applying retries if configured.
func (c *Chain) executeStep(ctx context.Context, step *chainStep) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if step.config.retry != nil {
retry := step.config.retry.Transform(WithContext(ctx))
// Wrap step execution to respect context
wrappedFn := func() error {
type result struct {
err error
}
done := make(chan result, 1)
go func() {
done <- result{err: step.execute()}
}()
select {
case res := <-done:
return res.err
case <-ctx.Done():
return ctx.Err()
}
}
return retry.Execute(wrappedFn)
}
// Non-retry case also respects context
type result struct {
err error
}
done := make(chan result, 1)
go func() {
done <- result{err: step.execute()}
}()
select {
case res := <-done:
return res.err
case <-ctx.Done():
return ctx.Err()
}
}
// enhanceError wraps an error with additional context from the step.
func (c *Chain) enhanceError(err error, step *chainStep) error {
if err == nil || !c.config.autoWrap {
// Return the error unchanged if nil or autoWrap is disabled
return err
}
// Initialize the base error
var baseError *Error
if e, ok := err.(*Error); ok {
// Copy existing *Error to preserve its properties
baseError = e.Copy()
} else {
// Create a new *Error wrapping the original
baseError = New(err.Error()).Wrap(err).WithStack()
}
if step != nil {
// Add step-specific context to the error
if step.config.category != "" && baseError.Category() == "" {
baseError.WithCategory(step.config.category)
}
if step.config.code != 0 && baseError.Code() == 0 {
baseError.WithCode(step.config.code)
}
for k, v := range step.config.context {
baseError.With(k, v)
}
for _, attr := range step.config.logAttrs {
baseError.With(attr.Key, attr.Value.Any())
}
if step.config.retry != nil && !baseError.HasContextKey(ctxRetry) {
// Mark the error as retryable if retries are configured
baseError.WithRetryable()
}
}
return baseError
}
+752 -213
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -31,7 +31,7 @@ func Code(err error) int {
if e, ok := err.(*Error); ok { if e, ok := err.(*Error); ok {
return e.Code() return e.Code()
} }
return 500 return DefaultCode
} }
// Context extracts the context map from an error, if it is an *Error. // Context extracts the context map from an error, if it is an *Error.
@@ -63,7 +63,12 @@ func Convert(err error) *Error {
} }
// Manual unwrapping as fallback // Manual unwrapping as fallback
visited := make(map[error]bool)
for unwrapped := err; unwrapped != nil; { for unwrapped := err; unwrapped != nil; {
if visited[unwrapped] {
break // Cycle detected
}
visited[unwrapped] = true
if e, ok := unwrapped.(*Error); ok { if e, ok := unwrapped.(*Error); ok {
return e return e
} }
@@ -229,12 +234,16 @@ func IsRetryable(err error) bool {
} }
} }
} }
// Fallback to context map // Check regular context
if e.context != nil { if e.context != nil {
if val, ok := e.context[ctxRetry].(bool); ok { if val, ok := e.context[ctxRetry].(bool); ok {
return val return val
} }
} }
// Check cause recursively
if e.cause != nil {
return IsRetryable(e.cause)
}
} }
lowerMsg := strings.ToLower(err.Error()) lowerMsg := strings.ToLower(err.Error())
return IsTimeout(err) || strings.Contains(lowerMsg, "retry") return IsTimeout(err) || strings.Contains(lowerMsg, "retry")
+225
View File
@@ -0,0 +1,225 @@
// File: inspect.go
// Updated to support both error and *Error with delegation for cleaner *Error handling
package errors
import (
stderrs "errors"
"fmt"
"strings"
"time"
)
// Inspect provides detailed examination of an error, handling both single errors and MultiError
func Inspect(err error) {
if err == nil {
fmt.Println("No error occurred")
return
}
fmt.Printf("\n=== Error Inspection ===\n")
fmt.Printf("Top-level error: %v\n", err)
fmt.Printf("Top-level error type: %T\n", err)
// Handle *Error directly
if e, ok := err.(*Error); ok {
InspectError(e)
return
}
// Handle MultiError
if multi, ok := err.(*MultiError); ok {
allErrors := multi.Errors()
fmt.Printf("\nContains %d errors:\n", len(allErrors))
for i, e := range allErrors {
fmt.Printf("\n--- Error %d ---\n", i+1)
inspectSingleError(e)
}
} else {
// Inspect single error if not MultiError or *Error
fmt.Println("\n--- Details ---")
inspectSingleError(err)
}
// Additional diagnostics
fmt.Println("\n--- Diagnostics ---")
if IsRetryable(err) {
fmt.Println("- Error chain contains retryable errors")
}
if IsTimeout(err) {
fmt.Println("- Error chain contains timeout errors")
}
if code := getErrorCode(err); code != 0 {
fmt.Printf("- Highest priority error code: %d\n", code)
}
fmt.Printf("========================\n\n")
}
// InspectError provides detailed inspection of a specific *Error instance
func InspectError(err *Error) {
if err == nil {
fmt.Println("No error occurred")
return
}
fmt.Printf("\n=== Error Inspection (*Error) ===\n")
fmt.Printf("Top-level error: %v\n", err)
fmt.Printf("Top-level error type: %T\n", err)
fmt.Println("\n--- Details ---")
inspectSingleError(err) // Delegate to handle unwrapping and details
// Additional diagnostics specific to *Error
fmt.Println("\n--- Diagnostics ---")
if IsRetryable(err) {
fmt.Println("- Error is retryable")
}
if IsTimeout(err) {
fmt.Println("- Error chain contains timeout errors")
}
if code := err.Code(); code != 0 {
fmt.Printf("- Error code: %d\n", code)
}
fmt.Printf("========================\n\n")
}
// inspectSingleError handles inspection of a single error (may be part of a chain)
func inspectSingleError(err error) {
if err == nil {
fmt.Println(" (nil error)")
return
}
fmt.Printf(" Error: %v\n", err)
fmt.Printf(" Type: %T\n", err)
// Handle wrapped errors, including *Error type
var currentErr error = err
depth := 0
for currentErr != nil {
prefix := strings.Repeat(" ", depth+1)
if depth > 0 {
fmt.Printf("%sWrapped Cause (%T): %v\n", prefix, currentErr, currentErr)
}
// Check if it's our specific *Error type
if e, ok := currentErr.(*Error); ok {
if name := e.Name(); name != "" {
fmt.Printf("%sName: %s\n", prefix, name)
}
if cat := e.Category(); cat != "" {
fmt.Printf("%sCategory: %s\n", prefix, cat)
}
if code := e.Code(); code != 0 {
fmt.Printf("%sCode: %d\n", prefix, code)
}
if ctx := e.Context(); len(ctx) > 0 {
fmt.Printf("%sContext:\n", prefix)
for k, v := range ctx {
fmt.Printf("%s %s: %v\n", prefix, k, v)
}
}
if stack := e.Stack(); len(stack) > 0 {
fmt.Printf("%sStack (Top 3):\n", prefix)
limit := 3
if len(stack) < limit {
limit = len(stack)
}
for i := 0; i < limit; i++ {
fmt.Printf("%s %s\n", prefix, stack[i])
}
if len(stack) > limit {
fmt.Printf("%s ... (%d more frames)\n", prefix, len(stack)-limit)
}
}
}
// Unwrap using standard errors.Unwrap and handle *Error Unwrap
var nextErr error
// Prioritize *Error's Unwrap if available AND it returns non-nil
if e, ok := currentErr.(*Error); ok {
unwrapped := e.Unwrap()
if unwrapped != nil {
nextErr = unwrapped
} else {
// If *Error.Unwrap returns nil, fall back to standard unwrap
// This handles cases where *Error might wrap a non-standard error
// or where its internal cause is deliberately nil.
nextErr = stderrs.Unwrap(currentErr)
}
} else {
nextErr = stderrs.Unwrap(currentErr) // Fall back to standard unwrap for non-*Error types
}
// Prevent infinite loops if Unwrap returns the same error, or stop if no more unwrapping
if nextErr == currentErr || nextErr == nil {
break
}
currentErr = nextErr
depth++
if depth > 10 { // Safety break for very deep or potentially cyclic chains
fmt.Printf("%s... (chain too deep or potential cycle)\n", strings.Repeat(" ", depth+1))
break
}
}
}
// getErrorCode traverses the error chain to find the highest priority code.
// It uses errors.As to find the first *Error in the chain.
func getErrorCode(err error) int {
var code int = 0 // Default code
var target *Error
if As(err, &target) { // Use the package's As helper
if target != nil { // Add nil check for safety
code = target.Code()
}
}
// If the top-level error is *Error and has a code, it might take precedence.
// This depends on desired logic. Let's keep it simple for now: first code found by As.
if code == 0 { // Only check top-level if As didn't find one with a code
if e, ok := err.(*Error); ok {
code = e.Code()
}
}
return code
}
// handleError demonstrates using Inspect with additional handling logic
func handleError(err error) {
fmt.Println("\n=== Processing Failure ===")
Inspect(err) // Use the primary Inspect function
// Additional handling based on inspection
code := getErrorCode(err) // Use the helper
switch {
case IsTimeout(err):
fmt.Println("\nAction: Check connectivity or increase timeout")
case code == 402: // Check code obtained via helper
fmt.Println("\nAction: Payment processing failed - notify billing")
default:
fmt.Println("\nAction: Generic failure handling")
}
}
// processOrder demonstrates Chain usage with Inspect
func processOrder() error {
validateInput := func() error { return nil }
processPayment := func() error { return stderrs.New("credit card declined") }
sendNotification := func() error { fmt.Println("Notification sent."); return nil }
logOrder := func() error { fmt.Println("Order logged."); return nil }
chain := NewChain(ChainWithTimeout(2*time.Second)).
Step(validateInput).Tag("validation").
Step(processPayment).Tag("billing").Code(402).Retry(3, 100*time.Millisecond, WithRetryIf(IsRetryable)).
Step(sendNotification).Optional().
Step(logOrder)
err := chain.Run()
if err != nil {
handleError(err) // Call the unified error handler
return err // Propagate the error if needed
}
fmt.Println("Order processed successfully!")
return nil
}
+126 -28
View File
@@ -1,11 +1,13 @@
package errors package errors
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"math/rand" "math/rand"
"strings" "strings"
"sync" "sync"
"time" "sync/atomic"
) )
// MultiError represents a thread-safe collection of errors with enhanced features. // MultiError represents a thread-safe collection of errors with enhanced features.
@@ -45,40 +47,56 @@ func NewMultiError(opts ...MultiErrorOption) *MultiError {
// Add appends an error to the collection with optional sampling, limit checks, and duplicate prevention. // Add appends an error to the collection with optional sampling, limit checks, and duplicate prevention.
// Ignores nil errors and duplicates based on string equality; thread-safe. // Ignores nil errors and duplicates based on string equality; thread-safe.
func (m *MultiError) Add(err error) { func (m *MultiError) Add(errs ...error) {
if err == nil { if len(errs) == 0 {
return return
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
// Check for duplicates by comparing error messages for _, err := range errs {
for _, e := range m.errors { if err == nil {
if e.Error() == err.Error() { continue
return
} }
}
// Apply sampling if enabled and collection isnt empty // Check for duplicates by comparing error messages
if m.sampling && len(m.errors) > 0 { duplicate := false
var r uint32 for _, e := range m.errors {
if m.rand != nil { if e.Error() == err.Error() {
r = uint32(m.rand.Int31n(100)) duplicate = true
} else { break
r = fastRand() % 100 }
} }
if r > m.sampleRate { // Accept if random value is within sample rate if duplicate {
return continue
} }
}
// Respect limit if set // Apply sampling if enabled and collection isnt empty
if m.limit > 0 && len(m.errors) >= m.limit { if m.sampling && len(m.errors) > 0 {
return var r uint32
} if m.rand != nil {
r = uint32(m.rand.Int31n(100))
} else {
r = fastRand() % 100
}
if r > m.sampleRate { // Accept if random value is within sample rate
continue
}
}
m.errors = append(m.errors, err) // Respect limit if set
if m.limit > 0 && len(m.errors) >= m.limit {
continue
}
m.errors = append(m.errors, err)
}
}
// Addf formats and adds a new error to the collection.
func (m *MultiError) Addf(format string, args ...interface{}) {
m.Add(Newf(format, args...))
} }
// Clear removes all errors from the collection. // Clear removes all errors from the collection.
@@ -300,6 +318,75 @@ func WithRand(r *rand.Rand) MultiErrorOption {
} }
} }
// MarshalJSON serializes the MultiError to JSON, including all contained errors and configuration metadata.
// Thread-safe; errors are serialized using their MarshalJSON method if available, otherwise as strings.
func (m *MultiError) MarshalJSON() ([]byte, error) {
m.mu.RLock()
defer m.mu.RUnlock()
// Get buffer from pool for efficiency
buf := jsonBufferPool.Get().(*bytes.Buffer)
defer jsonBufferPool.Put(buf)
buf.Reset()
// Create encoder
enc := json.NewEncoder(buf)
enc.SetEscapeHTML(false)
// Define JSON structure
type jsonError struct {
Error interface{} `json:"error"` // Holds either JSON-marshaled error or string
}
je := struct {
Count int `json:"count"` // Number of errors
Limit int `json:"limit,omitempty"` // Maximum error limit (omitted if 0)
Sampling bool `json:"sampling,omitempty"` // Whether sampling is enabled
SampleRate uint32 `json:"sample_rate,omitempty"` // Sampling rate (1-100, omitted if not sampling)
Errors []jsonError `json:"errors"` // List of errors
}{
Count: len(m.errors),
Limit: m.limit,
Sampling: m.sampling,
SampleRate: m.sampleRate,
}
// Serialize each error
je.Errors = make([]jsonError, len(m.errors))
for i, err := range m.errors {
if err == nil {
je.Errors[i] = jsonError{Error: nil}
continue
}
// Check if the error implements json.Marshaler
if marshaler, ok := err.(json.Marshaler); ok {
marshaled, err := marshaler.MarshalJSON()
if err != nil {
// Fallback to string if marshaling fails
je.Errors[i] = jsonError{Error: err.Error()}
} else {
var raw json.RawMessage = marshaled
je.Errors[i] = jsonError{Error: raw}
}
} else {
// Use error string for non-marshaler errors
je.Errors[i] = jsonError{Error: err.Error()}
}
}
// Encode JSON
if err := enc.Encode(je); err != nil {
return nil, fmt.Errorf("failed to marshal MultiError: %v", err)
}
// Remove trailing newline
result := buf.Bytes()
if len(result) > 0 && result[len(result)-1] == '\n' {
result = result[:len(result)-1]
}
return result, nil
}
// defaultFormat provides the default formatting for multiple errors. // defaultFormat provides the default formatting for multiple errors.
// Returns a semicolon-separated list prefixed with the error count (e.g., "errors(3): err1; err2; err3"). // Returns a semicolon-separated list prefixed with the error count (e.g., "errors(3): err1; err2; err3").
func defaultFormat(errs []error) string { func defaultFormat(errs []error) string {
@@ -316,10 +403,21 @@ func defaultFormat(errs []error) string {
// fastRand generates a quick pseudo-random number for sampling. // fastRand generates a quick pseudo-random number for sampling.
// Uses a simple xorshift algorithm based on the current time; not cryptographically secure. // Uses a simple xorshift algorithm based on the current time; not cryptographically secure.
var fastRandState uint32 = 1 // Must be non-zero
func fastRand() uint32 { func fastRand() uint32 {
r := uint32(time.Now().UnixNano()) for {
r ^= r << 13 // Atomically load the current state
r ^= r >> 17 old := atomic.LoadUint32(&fastRandState)
r ^= r << 5 // Xorshift computation
return r x := old
x ^= x << 13
x ^= x >> 17
x ^= x << 5
// Attempt to store the new state atomically
if atomic.CompareAndSwapUint32(&fastRandState, old, x) {
return x
}
// Otherwise retry
}
} }
+91 -9
View File
@@ -103,42 +103,124 @@ func (r *Retry) Execute(fn func() error) error {
var lastErr error var lastErr error
for attempt := 1; attempt <= r.maxAttempts; attempt++ { for attempt := 1; attempt <= r.maxAttempts; attempt++ {
// Check context before each attempt
select {
case <-r.ctx.Done():
return r.ctx.Err()
default:
}
err := fn() err := fn()
if err == nil { if err == nil {
return nil return nil
} }
// Check if retry is applicable; return immediately if not retryable lastErr = err
// Check if we should retry
if r.retryIf != nil && !r.retryIf(err) { if r.retryIf != nil && !r.retryIf(err) {
return err return err
} }
lastErr = err
if r.onRetry != nil { if r.onRetry != nil {
r.onRetry(attempt, err) r.onRetry(attempt, err)
} }
// Exit if this was the last attempt // Don't delay after last attempt
if attempt == r.maxAttempts { if attempt == r.maxAttempts {
break break
} }
// Calculate delay with backoff, cap at maxDelay, and apply jitter if enabled // Calculate delay with backoff
delay := r.backoff.Backoff(attempt, r.delay)
if r.maxDelay > 0 && delay > r.maxDelay {
delay = r.maxDelay
}
if r.jitter {
delay = addJitter(delay)
}
// Wait with context
select {
case <-r.ctx.Done():
return r.ctx.Err()
case <-time.After(delay):
}
}
return lastErr
}
// ExecuteContext runs the provided function with retry logic, respecting context cancellation.
// Returns nil on success or the last error if all attempts fail or context is cancelled.
func (r *Retry) ExecuteContext(ctx context.Context, fn func() error) error {
var lastErr error
// If the retry instance already has a context, use it. Otherwise, use the provided one.
// If both are provided, maybe create a derived context? For now, prioritize the one from WithContext.
execCtx := r.ctx
if execCtx == context.Background() && ctx != nil { // Use provided ctx if retry ctx is default and provided one isn't nil
execCtx = ctx
} else if ctx == nil { // Ensure we always have a non-nil context
execCtx = context.Background()
}
// Note: This logic might need refinement depending on how contexts should interact.
// A safer approach might be: if r.ctx != background, use it. Else use provided ctx.
for attempt := 1; attempt <= r.maxAttempts; attempt++ {
// Check context before executing the function
select {
case <-execCtx.Done():
return execCtx.Err() // Return context error immediately
default:
// Context is okay, proceed
}
err := fn()
if err == nil {
return nil // Success
}
// Check if retry is applicable based on the error
if r.retryIf != nil && !r.retryIf(err) {
return err // Not retryable, return the error
}
lastErr = err // Store the last encountered error
// Execute the OnRetry callback if configured
if r.onRetry != nil {
r.onRetry(attempt, err)
}
// Exit loop if this was the last attempt
if attempt == r.maxAttempts {
break
}
// --- Calculate and apply delay ---
currentDelay := r.backoff.Backoff(attempt, r.delay) currentDelay := r.backoff.Backoff(attempt, r.delay)
if currentDelay > r.maxDelay { if r.maxDelay > 0 && currentDelay > r.maxDelay { // Check maxDelay > 0 before capping
currentDelay = r.maxDelay currentDelay = r.maxDelay
} }
if r.jitter { if r.jitter {
currentDelay = addJitter(currentDelay) currentDelay = addJitter(currentDelay)
} }
if currentDelay < 0 { // Ensure delay isn't negative after jitter
// Wait with respect to context cancellation or timeout currentDelay = 0
}
// --- Wait for the delay or context cancellation ---
select { select {
case <-r.ctx.Done(): case <-execCtx.Done():
return r.ctx.Err() // If context is cancelled during the wait, return the context error
// Often more informative than returning the last application error.
return execCtx.Err()
case <-time.After(currentDelay): case <-time.After(currentDelay):
// Wait finished, continue to the next attempt
} }
} }
// All attempts failed, return the last error encountered
return lastErr return lastErr
} }
+37 -3
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"sort" "sort"
"strings" "strings"
"time"
) )
// Palette defines ANSI color codes for various log components. // Palette defines ANSI color codes for various log components.
@@ -76,8 +77,10 @@ var lightPalette = Palette{
// writing the result to the provided writer. // writing the result to the provided writer.
// Thread-safe if the underlying writer is thread-safe. // Thread-safe if the underlying writer is thread-safe.
type ColorizedHandler struct { type ColorizedHandler struct {
w io.Writer // Destination for colored log output w io.Writer // Destination for colored log output
palette Palette // Color scheme for formatting palette Palette // Color scheme for formatting
showTime bool // Whether to display timestamps
timeFormat string // Format for timestamps (defaults to time.RFC3339)
} }
// ColorOption defines a configuration function for ColorizedHandler. // ColorOption defines a configuration function for ColorizedHandler.
@@ -104,7 +107,12 @@ func WithColorPallet(pallet Palette) ColorOption {
// logger := ll.New("app").Enable().Handler(handler) // logger := ll.New("app").Enable().Handler(handler)
// logger.Info("Test") // Output: [app] <colored INFO>: Test // logger.Info("Test") // Output: [app] <colored INFO>: Test
func NewColorizedHandler(w io.Writer, opts ...ColorOption) *ColorizedHandler { func NewColorizedHandler(w io.Writer, opts ...ColorOption) *ColorizedHandler {
c := &ColorizedHandler{w: w} // Initialize with writer // Initialize with writer
c := &ColorizedHandler{w: w,
showTime: false,
timeFormat: time.RFC3339,
}
// Apply configuration options // Apply configuration options
for _, opt := range opts { for _, opt := range opts {
opt(c) opt(c)
@@ -136,6 +144,19 @@ func (h *ColorizedHandler) Handle(e *lx.Entry) error {
} }
} }
// Timestamped enables or disables timestamp display and optionally sets a custom time format.
// If format is empty, defaults to RFC3339.
// Example:
//
// handler := NewColorizedHandler(os.Stdout).Timestamped(true, time.StampMilli)
// // Output: Jan 02 15:04:05.000 [app] INFO: Test
func (h *ColorizedHandler) Timestamped(enable bool, format ...string) {
h.showTime = enable
if len(format) > 0 && format[0] != "" {
h.timeFormat = format[0]
}
}
// handleRegularOutput handles normal log entries. // handleRegularOutput handles normal log entries.
// It formats the entry with colored namespace, level, message, fields, and stack trace (if present), // It formats the entry with colored namespace, level, message, fields, and stack trace (if present),
// writing the result to the handler's writer. // writing the result to the handler's writer.
@@ -146,6 +167,12 @@ func (h *ColorizedHandler) Handle(e *lx.Entry) error {
func (h *ColorizedHandler) handleRegularOutput(e *lx.Entry) error { func (h *ColorizedHandler) handleRegularOutput(e *lx.Entry) error {
var builder strings.Builder // Buffer for building formatted output var builder strings.Builder // Buffer for building formatted output
// Add timestamp if enabled
if h.showTime {
builder.WriteString(e.Timestamp.Format(h.timeFormat))
builder.WriteString(lx.Space)
}
// Format namespace with colors // Format namespace with colors
h.formatNamespace(&builder, e) h.formatNamespace(&builder, e)
@@ -345,6 +372,13 @@ func (h *ColorizedHandler) formatStack(b *strings.Builder, stack []byte) {
// h.handleDumpOutput(&lx.Entry{Class: lx.ClassDump, Message: "pos 00 hex: 61 62 'ab'"}) // Writes colored dump // h.handleDumpOutput(&lx.Entry{Class: lx.ClassDump, Message: "pos 00 hex: 61 62 'ab'"}) // Writes colored dump
func (h *ColorizedHandler) handleDumpOutput(e *lx.Entry) error { func (h *ColorizedHandler) handleDumpOutput(e *lx.Entry) error {
var builder strings.Builder var builder strings.Builder
// Add timestamp if enabled
if h.showTime {
builder.WriteString(e.Timestamp.Format(h.timeFormat))
builder.WriteString(lx.Newline)
}
// Write colored BEGIN separator // Write colored BEGIN separator
builder.WriteString(h.palette.Title) builder.WriteString(h.palette.Title)
builder.WriteString("---- BEGIN DUMP ----") builder.WriteString("---- BEGIN DUMP ----")
+22 -2
View File
@@ -11,8 +11,10 @@ import (
// Useful for testing or buffering logs for later inspection. // Useful for testing or buffering logs for later inspection.
// It maintains a thread-safe slice of log entries, protected by a read-write mutex. // It maintains a thread-safe slice of log entries, protected by a read-write mutex.
type MemoryHandler struct { type MemoryHandler struct {
mu sync.RWMutex // Protects concurrent access to entries mu sync.RWMutex // Protects concurrent access to entries
entries []*lx.Entry // Slice of stored log entries entries []*lx.Entry // Slice of stored log entries
showTime bool // Whether to show timestamps when dumping
timeFormat string // Time format for dumping
} }
// NewMemoryHandler creates a new MemoryHandler. // NewMemoryHandler creates a new MemoryHandler.
@@ -28,6 +30,23 @@ func NewMemoryHandler() *MemoryHandler {
} }
} }
// Timestamped enables/disables timestamp display when dumping and optionally sets a time format.
// Consistent with TextHandler and ColorizedHandler signature.
// Example:
//
// handler.Timestamped(true) // Enable with default format
// handler.Timestamped(true, time.StampMilli) // Enable with custom format
// handler.Timestamped(false) // Disable
func (h *MemoryHandler) Timestamped(enable bool, format ...string) {
h.mu.Lock()
defer h.mu.Unlock()
h.showTime = enable
if len(format) > 0 && format[0] != "" {
h.timeFormat = format[0]
}
}
// Handle stores the log entry in memory. // Handle stores the log entry in memory.
// It appends the provided entry to the entries slice, ensuring thread-safety with a write lock. // It appends the provided entry to the entries slice, ensuring thread-safety with a write lock.
// Always returns nil, as it does not perform I/O operations. // Always returns nil, as it does not perform I/O operations.
@@ -82,6 +101,7 @@ func (h *MemoryHandler) Dump(w io.Writer) error {
// Create a temporary TextHandler to format entries // Create a temporary TextHandler to format entries
tempHandler := NewTextHandler(w) tempHandler := NewTextHandler(w)
tempHandler.Timestamped(h.showTime, h.timeFormat)
// Process each entry through the TextHandler // Process each entry through the TextHandler
for _, entry := range h.entries { for _, entry := range h.entries {
+34 -2
View File
@@ -6,6 +6,7 @@ import (
"io" "io"
"sort" "sort"
"strings" "strings"
"time"
) )
// TextHandler is a handler that outputs log entries as plain text. // TextHandler is a handler that outputs log entries as plain text.
@@ -13,7 +14,9 @@ import (
// writing the result to the provided writer. // writing the result to the provided writer.
// Thread-safe if the underlying writer is thread-safe. // Thread-safe if the underlying writer is thread-safe.
type TextHandler struct { type TextHandler struct {
w io.Writer // Destination for formatted log output w io.Writer // Destination for formatted log output
showTime bool // Whether to display timestamps
timeFormat string // Format for timestamps (defaults to time.RFC3339)
} }
// NewTextHandler creates a new TextHandler writing to the specified writer. // NewTextHandler creates a new TextHandler writing to the specified writer.
@@ -24,7 +27,24 @@ type TextHandler struct {
// logger := ll.New("app").Enable().Handler(handler) // logger := ll.New("app").Enable().Handler(handler)
// logger.Info("Test") // Output: [app] INFO: Test // logger.Info("Test") // Output: [app] INFO: Test
func NewTextHandler(w io.Writer) *TextHandler { func NewTextHandler(w io.Writer) *TextHandler {
return &TextHandler{w: w} return &TextHandler{
w: w,
showTime: false,
timeFormat: time.RFC3339,
}
}
// Timestamped enables or disables timestamp display and optionally sets a custom time format.
// If format is empty, defaults to RFC3339.
// Example:
//
// handler := NewTextHandler(os.Stdout).TextWithTime(true, time.StampMilli)
// // Output: Jan 02 15:04:05.000 [app] INFO: Test
func (h *TextHandler) Timestamped(enable bool, format ...string) {
h.showTime = enable
if len(format) > 0 && format[0] != "" {
h.timeFormat = format[0]
}
} }
// Handle processes a log entry and writes it as plain text. // Handle processes a log entry and writes it as plain text.
@@ -60,6 +80,12 @@ func (h *TextHandler) Handle(e *lx.Entry) error {
func (h *TextHandler) handleRegularOutput(e *lx.Entry) error { func (h *TextHandler) handleRegularOutput(e *lx.Entry) error {
var builder strings.Builder // Buffer for building formatted output var builder strings.Builder // Buffer for building formatted output
// Add timestamp if enabled
if h.showTime {
builder.WriteString(e.Timestamp.Format(h.timeFormat))
builder.WriteString(lx.Space)
}
// Format namespace based on style // Format namespace based on style
switch e.Style { switch e.Style {
case lx.NestedPath: case lx.NestedPath:
@@ -140,6 +166,12 @@ func (h *TextHandler) handleDumpOutput(e *lx.Entry) error {
// For text handler, we just add a newline before dump output // For text handler, we just add a newline before dump output
var builder strings.Builder // Buffer for building formatted output var builder strings.Builder // Buffer for building formatted output
// Add timestamp if enabled
if h.showTime {
builder.WriteString(e.Timestamp.Format(h.timeFormat))
builder.WriteString(lx.Newline)
}
// Add separator lines and dump content // Add separator lines and dump content
builder.WriteString("---- BEGIN DUMP ----\n") builder.WriteString("---- BEGIN DUMP ----\n")
builder.WriteString(e.Message) builder.WriteString(e.Message)
+36
View File
@@ -1116,6 +1116,24 @@ func (l *Logger) Style(style lx.StyleType) *Logger {
return l return l
} }
// Timestamped enables or disables timestamp logging for the logger and optionally sets the timestamp format.
// It is thread-safe, using a write lock to ensure safe concurrent access.
// If the logger's handler supports the lx.Timestamper interface, the timestamp settings are applied.
// The method returns the logger instance to support method chaining.
// Parameters:
//
// enable: Boolean to enable or disable timestamp logging
// format: Optional string(s) to specify the timestamp format
func (l *Logger) Timestamped(enable bool, format ...string) *Logger {
l.mu.Lock()
defer l.mu.Unlock()
if h, ok := l.handler.(lx.Timestamper); ok {
h.Timestamped(enable, format...)
}
return l
}
// Use adds a middleware function to process log entries before they are handled, returning // Use adds a middleware function to process log entries before they are handled, returning
// a Middleware handle for removal. Middleware returning a non-nil error stops the log. // a Middleware handle for removal. Middleware returning a non-nil error stops the log.
// It is thread-safe using a write lock. // It is thread-safe using a write lock.
@@ -1389,6 +1407,24 @@ func WithHandler(handler lx.Handler) Option {
} }
} }
// WithTimestamped returns an Option that configures timestamp settings for the logger's existing handler.
// It enables or disables timestamp logging and optionally sets the timestamp format if the handler
// supports the lx.Timestamper interface. If no handler is set, the function has no effect.
// Parameters:
//
// enable: Boolean to enable or disable timestamp logging
// format: Optional string(s) to specify the timestamp format
func WithTimestamped(enable bool, format ...string) Option {
return func(l *Logger) {
if l.handler != nil { // Check if a handler is set
// Verify if the handler supports the lx.Timestamper interface
if h, ok := l.handler.(lx.Timestamper); ok {
h.Timestamped(enable, format...) // Apply timestamp settings to the handler
}
}
}
}
// WithLevel sets the minimum log level for the logger as a functional option for // WithLevel sets the minimum log level for the logger as a functional option for
// configuring a new logger instance. // configuring a new logger instance.
// Example: // Example:
+10
View File
@@ -124,6 +124,16 @@ type Handler interface {
Handle(e *Entry) error // Processes a log entry, returning any error Handle(e *Entry) error // Processes a log entry, returning any error
} }
// Timestamper defines an interface for handlers that support timestamp configuration.
// It includes a method to enable or disable timestamp logging and optionally set the timestamp format.
type Timestamper interface {
// Timestamped enables or disables timestamp logging and allows specifying an optional format.
// Parameters:
// enable: Boolean to enable or disable timestamp logging
// format: Optional string(s) to specify the timestamp format
Timestamped(enable bool, format ...string)
}
// ClassType represents the type of a log entry. // ClassType represents the type of a log entry.
// It is an integer type used to categorize log entries (Text, JSON, Dump, Special, Raw), // It is an integer type used to categorize log entries (Text, JSON, Dump, Special, Raw),
// influencing how handlers process and format them. // influencing how handlers process and format them.
+3 -3
View File
@@ -28,7 +28,7 @@ go get github.com/olekukonko/tablewriter@v0.0.5
#### Latest Version #### Latest Version
The latest stable version The latest stable version
```bash ```bash
go get github.com/olekukonko/tablewriter@v1.0.7 go get github.com/olekukonko/tablewriter@v1.0.9
``` ```
**Warning:** Version `v1.0.0` contains missing functionality and should not be used. **Warning:** Version `v1.0.0` contains missing functionality and should not be used.
@@ -62,7 +62,7 @@ func main() {
data := [][]string{ data := [][]string{
{"Package", "Version", "Status"}, {"Package", "Version", "Status"},
{"tablewriter", "v0.0.5", "legacy"}, {"tablewriter", "v0.0.5", "legacy"},
{"tablewriter", "v1.0.7", "latest"}, {"tablewriter", "v1.0.9", "latest"},
} }
table := tablewriter.NewWriter(os.Stdout) table := tablewriter.NewWriter(os.Stdout)
@@ -77,7 +77,7 @@ func main() {
│ PACKAGE │ VERSION │ STATUS │ │ PACKAGE │ VERSION │ STATUS │
├─────────────┼─────────┼────────┤ ├─────────────┼─────────┼────────┤
│ tablewriter │ v0.0.5 │ legacy │ │ tablewriter │ v0.0.5 │ legacy │
│ tablewriter │ v1.0.7 │ latest │ │ tablewriter │ v1.0.9 │ latest │
└─────────────┴─────────┴────────┘ └─────────────┴─────────┴────────┘
``` ```
+6
View File
@@ -688,6 +688,12 @@ func (bb *BehaviorConfigBuilder) WithCompactMerge(state tw.State) *BehaviorConfi
return bb return bb
} }
// WithAutoHeader enables/disables automatic header extraction for structs in Bulk.
func (bb *BehaviorConfigBuilder) WithAutoHeader(state tw.State) *BehaviorConfigBuilder {
bb.config.Structs.AutoHeader = state
return bb
}
// ColumnConfigBuilder configures column-specific settings // ColumnConfigBuilder configures column-specific settings
type ColumnConfigBuilder struct { type ColumnConfigBuilder struct {
parent *ConfigBuilder parent *ConfigBuilder
+12
View File
@@ -717,6 +717,10 @@ func defaultConfig() Config {
Behavior: tw.Behavior{ Behavior: tw.Behavior{
AutoHide: tw.Off, AutoHide: tw.Off,
TrimSpace: tw.On, TrimSpace: tw.On,
Structs: tw.Struct{
AutoHeader: tw.Off,
Tags: []string{"json", "db"},
},
}, },
} }
} }
@@ -844,6 +848,14 @@ func mergeConfig(dst, src Config) Config {
dst.Behavior.Compact = src.Behavior.Compact dst.Behavior.Compact = src.Behavior.Compact
dst.Behavior.Header = src.Behavior.Header dst.Behavior.Header = src.Behavior.Header
dst.Behavior.Footer = src.Behavior.Footer dst.Behavior.Footer = src.Behavior.Footer
dst.Behavior.Footer = src.Behavior.Footer
dst.Behavior.Structs.AutoHeader = src.Behavior.Structs.AutoHeader
// check lent of tags
if len(src.Behavior.Structs.Tags) > 0 {
dst.Behavior.Structs.Tags = src.Behavior.Structs.Tags
}
if src.Widths.Global != 0 { if src.Widths.Global != 0 {
dst.Widths.Global = src.Widths.Global dst.Widths.Global = src.Widths.Global
+14 -1
View File
@@ -523,9 +523,22 @@ func (f *Blueprint) renderLine(ctx tw.Formatting) {
isTotalPattern := false isTotalPattern := false
// Case-insensitive check for "total"
if isHMergeStart && colIndex > 0 {
if prevCellCtx, ok := ctx.Row.Current[colIndex-1]; ok {
if strings.Contains(strings.ToLower(prevCellCtx.Data), "total") {
isTotalPattern = true
f.logger.Debugf("renderLine: total pattern in row in %d", colIndex)
}
}
}
// Get the alignment from the configuration
align = cellCtx.Align
// Override alignment for footer merged cells // Override alignment for footer merged cells
if (ctx.Row.Position == tw.Footer && isHMergeStart) || isTotalPattern { if (ctx.Row.Position == tw.Footer && isHMergeStart) || isTotalPattern {
if align != tw.AlignRight { if align == tw.AlignNone {
f.logger.Debugf("renderLine: Applying AlignRight HMerge/TOTAL override for Footer col %d. Original/default align was: %s", colIndex, align) f.logger.Debugf("renderLine: Applying AlignRight HMerge/TOTAL override for Footer col %d. Original/default align was: %s", colIndex, align)
align = tw.AlignRight align = tw.AlignRight
} }
+3 -4
View File
@@ -1,7 +1,6 @@
package tablewriter package tablewriter
import ( import (
"fmt"
"github.com/olekukonko/errors" "github.com/olekukonko/errors"
"github.com/olekukonko/tablewriter/pkg/twwidth" "github.com/olekukonko/tablewriter/pkg/twwidth"
"github.com/olekukonko/tablewriter/tw" "github.com/olekukonko/tablewriter/tw"
@@ -90,7 +89,7 @@ func (t *Table) Start() error {
if !t.renderer.Config().Streaming { if !t.renderer.Config().Streaming {
// Check if the configured renderer actually supports streaming. // Check if the configured renderer actually supports streaming.
t.logger.Error("Configured renderer does not support streaming.") t.logger.Error("Configured renderer does not support streaming.")
return fmt.Errorf("renderer does not support streaming") return errors.Newf("renderer does not support streaming")
} }
//t.renderer.Start(t.writer) //t.renderer.Start(t.writer)
@@ -208,7 +207,7 @@ func (t *Table) streamAppendRow(row interface{}) error {
rawCellsSlice, err := t.convertCellsToStrings(row, t.config.Row) rawCellsSlice, err := t.convertCellsToStrings(row, t.config.Row)
if err != nil { if err != nil {
t.logger.Errorf("streamAppendRow: Failed to convert row to strings: %v", err) t.logger.Errorf("streamAppendRow: Failed to convert row to strings: %v", err)
return fmt.Errorf("failed to convert row to strings: %w", err) return errors.Newf("failed to convert row to strings").Wrap(err)
} }
if len(rawCellsSlice) == 0 { if len(rawCellsSlice) == 0 {
@@ -221,7 +220,7 @@ func (t *Table) streamAppendRow(row interface{}) error {
} }
if err := t.ensureStreamWidthsCalculated(rawCellsSlice, t.config.Row); err != nil { if err := t.ensureStreamWidthsCalculated(rawCellsSlice, t.config.Row); err != nil {
return fmt.Errorf("failed to establish stream column count/widths: %w", err) return errors.New("failed to establish stream column count/widths").Wrap(err)
} }
// Now, check for column mismatch if a column count has been established. // Now, check for column mismatch if a column count has been established.
+45 -24
View File
@@ -2,7 +2,6 @@ package tablewriter
import ( import (
"bytes" "bytes"
"fmt"
"github.com/olekukonko/errors" "github.com/olekukonko/errors"
"github.com/olekukonko/ll" "github.com/olekukonko/ll"
"github.com/olekukonko/ll/lh" "github.com/olekukonko/ll/lh"
@@ -180,65 +179,87 @@ func (t *Table) Caption(caption tw.Caption) *Table { // This is the one we modif
// This method always contributes to a single logical row in the table. // This method always contributes to a single logical row in the table.
// To add multiple distinct rows, call Append multiple times (once for each row's data) // To add multiple distinct rows, call Append multiple times (once for each row's data)
// or use the Bulk() method if providing a slice where each element is a row. // or use the Bulk() method if providing a slice where each element is a row.
func (t *Table) Append(rows ...interface{}) error { // rows is already []interface{} func (t *Table) Append(rows ...interface{}) error {
t.ensureInitialized() t.ensureInitialized()
if t.config.Stream.Enable && t.hasPrinted { if t.config.Stream.Enable && t.hasPrinted {
// Streaming logic remains unchanged, as AutoHeader is a batch-mode concept.
t.logger.Debugf("Append() called in streaming mode with %d items for a single row", len(rows)) t.logger.Debugf("Append() called in streaming mode with %d items for a single row", len(rows))
var rowItemForStream interface{} var rowItemForStream interface{}
if len(rows) == 1 { if len(rows) == 1 {
rowItemForStream = rows[0] rowItemForStream = rows[0]
} else { } else {
rowItemForStream = rows // Pass the slice of items if multiple args rowItemForStream = rows
} }
if err := t.streamAppendRow(rowItemForStream); err != nil { if err := t.streamAppendRow(rowItemForStream); err != nil {
t.logger.Errorf("Error rendering streaming row: %v", err) t.logger.Errorf("Error rendering streaming row: %v", err)
return fmt.Errorf("failed to stream append row: %w", err) return errors.Newf("failed to stream append row").Wrap(err)
} }
return nil return nil
} }
//Batch Mode Logic // Batch Mode Logic
t.logger.Debugf("Append (Batch) received %d arguments: %v", len(rows), rows) t.logger.Debugf("Append (Batch) received %d arguments: %v", len(rows), rows)
var cellsSource interface{} var cellsSource interface{}
if len(rows) == 1 { if len(rows) == 1 {
cellsSource = rows[0] cellsSource = rows[0]
t.logger.Debug("Append (Batch): Single argument provided. Treating it as the source for row cells.")
} else { } else {
cellsSource = rows // 'rows' is []interface{} containing all arguments cellsSource = rows
t.logger.Debug("Append (Batch): Multiple arguments provided. Treating them directly as cells for one row.") }
// Check if we should attempt to auto-generate headers from this append operation.
// Conditions: AutoHeader is on, no headers are set yet, and this is the first data row.
isFirstRow := len(t.rows) == 0
if t.config.Behavior.Structs.AutoHeader.Enabled() && len(t.headers) == 0 && isFirstRow {
t.logger.Debug("Append: Triggering AutoHeader for the first row.")
headers := t.extractHeadersFromStruct(cellsSource)
if len(headers) > 0 {
// Set the extracted headers. The Header() method handles the rest.
t.Header(headers)
}
} }
if err := t.appendSingle(cellsSource); err != nil { // The rest of the function proceeds as before, converting the data to string lines.
lines, err := t.toStringLines(cellsSource, t.config.Row)
if err != nil {
t.logger.Errorf("Append (Batch) failed for cellsSource %v: %v", cellsSource, err) t.logger.Errorf("Append (Batch) failed for cellsSource %v: %v", cellsSource, err)
return err return err
} }
t.rows = append(t.rows, lines)
t.logger.Debugf("Append (Batch) completed for one row, total rows in table: %d", len(t.rows)) t.logger.Debugf("Append (Batch) completed for one row, total rows in table: %d", len(t.rows))
return nil return nil
} }
// Bulk adds multiple rows from a slice to the table (legacy method). // Bulk adds multiple rows from a slice to the table.
// Parameter rows must be a slice compatible with stringer or []string. // If Behavior.AutoHeader is enabled, no headers set, and rows is a slice of structs,
// Returns an error if the input is invalid or appending fails. // automatically extracts/sets headers from the first struct.
func (t *Table) Bulk(rows interface{}) error { func (t *Table) Bulk(rows interface{}) error {
t.logger.Debug("Starting Bulk operation")
rv := reflect.ValueOf(rows) rv := reflect.ValueOf(rows)
if rv.Kind() != reflect.Slice { if rv.Kind() != reflect.Slice {
err := errors.Newf("Bulk expects a slice, got %T", rows) return errors.Newf("Bulk expects a slice, got %T", rows)
t.logger.Debugf("Bulk error: %v", err)
return err
} }
if rv.Len() == 0 {
return nil
}
// AutoHeader logic remains here, as it's a "Bulk" operation concept.
if t.config.Behavior.Structs.AutoHeader.Enabled() && len(t.headers) == 0 {
first := rv.Index(0).Interface()
// We can now correctly get headers from pointers or embedded structs
headers := t.extractHeadersFromStruct(first)
if len(headers) > 0 {
t.Header(headers)
}
}
// The rest of the logic is now just a loop over Append.
for i := 0; i < rv.Len(); i++ { for i := 0; i < rv.Len(); i++ {
row := rv.Index(i).Interface() row := rv.Index(i).Interface()
t.logger.Debugf("Processing bulk row %d: %v", i, row) if err := t.Append(row); err != nil { // Use Append
if err := t.appendSingle(row); err != nil {
t.logger.Debugf("Bulk append failed at index %d: %v", i, err)
return err return err
} }
} }
t.logger.Debugf("Bulk completed, processed %d rows", rv.Len())
return nil return nil
} }
@@ -1383,13 +1404,13 @@ func (t *Table) render() error {
if err != nil { if err != nil {
t.writer = originalWriter t.writer = originalWriter
t.logger.Errorf("prepareContexts failed: %v", err) t.logger.Errorf("prepareContexts failed: %v", err)
return fmt.Errorf("failed to prepare table contexts: %w", err) return errors.Newf("failed to prepare table contexts").Wrap(err)
} }
if err := ctx.renderer.Start(t.writer); err != nil { if err := ctx.renderer.Start(t.writer); err != nil {
t.writer = originalWriter t.writer = originalWriter
t.logger.Errorf("Renderer Start() error: %v", err) t.logger.Errorf("Renderer Start() error: %v", err)
return fmt.Errorf("renderer start failed: %w", err) return errors.Newf("renderer start failed").Wrap(err)
} }
renderError := false renderError := false
@@ -1404,7 +1425,7 @@ func (t *Table) render() error {
if renderErr := renderFn(ctx, mctx); renderErr != nil { if renderErr := renderFn(ctx, mctx); renderErr != nil {
t.logger.Errorf("Renderer section error (%s): %v", sectionName, renderErr) t.logger.Errorf("Renderer section error (%s): %v", sectionName, renderErr)
if !renderError { if !renderError {
firstRenderErr = fmt.Errorf("failed to render %s section: %w", sectionName, renderErr) firstRenderErr = errors.Newf("failed to render %s section", sectionName).Wrap(renderErr)
} }
renderError = true renderError = true
break break
@@ -1414,7 +1435,7 @@ func (t *Table) render() error {
if closeErr := ctx.renderer.Close(); closeErr != nil { if closeErr := ctx.renderer.Close(); closeErr != nil {
t.logger.Errorf("Renderer Close() error: %v", closeErr) t.logger.Errorf("Renderer Close() error: %v", closeErr)
if !renderError { if !renderError {
firstRenderErr = fmt.Errorf("renderer close failed: %w", closeErr) firstRenderErr = errors.Newf("renderer close failed").Wrap(closeErr)
} }
renderError = true renderError = true
} }
+15
View File
@@ -141,6 +141,18 @@ type Compact struct {
Merge State // Merge enables compact width calculation during cell merging, optimizing space allocation. Merge State // Merge enables compact width calculation during cell merging, optimizing space allocation.
} }
// Struct holds settings for struct-based operations like AutoHeader.
type Struct struct {
// AutoHeader automatically extracts and sets headers from struct fields when Bulk is called with a slice of structs.
// Uses JSON tags if present, falls back to field names (title-cased). Skips unexported or json:"-" fields.
// Enabled by default for convenience.
AutoHeader State
// Tags is a priority-ordered list of struct tag keys to check for header names.
// The first tag found on a field will be used. Defaults to ["json", "db"].
Tags []string
}
// Behavior defines settings that control table rendering behaviors, such as column visibility and content formatting. // Behavior defines settings that control table rendering behaviors, such as column visibility and content formatting.
type Behavior struct { type Behavior struct {
AutoHide State // AutoHide determines whether empty columns are hidden. Ignored in streaming mode. AutoHide State // AutoHide determines whether empty columns are hidden. Ignored in streaming mode.
@@ -152,6 +164,9 @@ type Behavior struct {
// Compact enables optimized width calculation for merged cells, such as in horizontal merges, // Compact enables optimized width calculation for merged cells, such as in horizontal merges,
// by systematically determining the most efficient width instead of scaling by the number of columns. // by systematically determining the most efficient width instead of scaling by the number of columns.
Compact Compact Compact Compact
// Structs contains settings for how struct data is processed.
Structs Struct
} }
// Padding defines the spacing characters around cell content in all four directions. // Padding defines the spacing characters around cell content in all four directions.
+106 -72
View File
@@ -1197,6 +1197,10 @@ func (t *Table) convertToString(value interface{}) string {
// convertItemToCells is responsible for converting a single input item (which could be // convertItemToCells is responsible for converting a single input item (which could be
// a struct, a basic type, or an item implementing Stringer/Formatter) into a slice // a struct, a basic type, or an item implementing Stringer/Formatter) into a slice
// of strings, where each string represents a cell for the table row. // of strings, where each string represents a cell for the table row.
// zoo.go
// convertItemToCells is responsible for converting a single input item into a slice of strings.
// It now uses the unified struct parser for structs.
func (t *Table) convertItemToCells(item interface{}) ([]string, error) { func (t *Table) convertItemToCells(item interface{}) ([]string, error) {
t.logger.Debugf("convertItemToCells: Converting item of type %T", item) t.logger.Debugf("convertItemToCells: Converting item of type %T", item)
@@ -1204,10 +1208,10 @@ func (t *Table) convertItemToCells(item interface{}) ([]string, error) {
if t.stringer != nil { if t.stringer != nil {
res, err := t.convertToStringer(item) res, err := t.convertToStringer(item)
if err == nil { if err == nil {
t.logger.Debugf("convertItemToCells: Used custom table stringer (t.stringer) for type %T. Produced %d cells: %v", item, len(res), res) t.logger.Debugf("convertItemToCells: Used custom table stringer for type %T. Produced %d cells: %v", item, len(res), res)
return res, nil return res, nil
} }
t.logger.Warnf("convertItemToCells: Custom table stringer (t.stringer) was set but incompatible or errored for type %T: %v. Will attempt other conversion methods.", item, err) t.logger.Warnf("convertItemToCells: Custom table stringer was set but incompatible for type %T: %v. Will attempt other methods.", item, err)
} }
// 2. Handle untyped nil directly. // 2. Handle untyped nil directly.
@@ -1216,85 +1220,26 @@ func (t *Table) convertItemToCells(item interface{}) ([]string, error) {
return []string{""}, nil return []string{""}, nil
} }
itemValue := reflect.ValueOf(item) // 3. Use the new unified struct parser. It handles pointers and embedding.
itemType := itemValue.Type() // We only care about the values it returns.
_, values := t.extractFieldsAndValuesFromStruct(item)
// 3. Handle pointers: Dereference pointers to get to the underlying struct or value. if values != nil {
if itemType.Kind() == reflect.Ptr { t.logger.Debugf("convertItemToCells: Structs %T reflected into %d cells: %v", item, len(values), values)
if itemValue.IsNil() { return values, nil
t.logger.Debugf("convertItemToCells: Item is a nil pointer of type %s. Returning single empty cell.", itemType.String())
return []string{""}, nil
}
itemValue = itemValue.Elem()
itemType = itemValue.Type()
t.logger.Debugf("convertItemToCells: Dereferenced pointer, now processing type %s.", itemType.String())
} }
// 4. Special handling for structs: // 4. Fallback for any other single item (e.g., basic types, or types that implement Stringer/Formatter).
if itemType.Kind() == reflect.Struct { // This code path is now for non-struct types.
// Check if the original item (before potential dereference) implements Formatter or Stringer.
if formatter, ok := item.(tw.Formatter); ok {
t.logger.Debugf("convertItemToCells: Struct item (type %s) is tw.Formatter. Using Format(). Resulting in 1 cell.", itemType.Name())
return []string{formatter.Format()}, nil
}
if stringer, ok := item.(fmt.Stringer); ok {
t.logger.Debugf("convertItemToCells: Struct item (type %s) is fmt.Stringer. Using String(). Resulting in 1 cell.", itemType.Name())
return []string{stringer.String()}, nil
}
t.logger.Debugf("convertItemToCells: Item is a struct (type %s). Attempting generic field reflection to expand into multiple cells.", itemType.Name())
numFields := itemValue.NumField()
structCells := make([]string, 0, numFields)
hasProcessableFields := false
for i := 0; i < numFields; i++ {
fieldMeta := itemType.Field(i)
if fieldMeta.PkgPath != "" {
t.logger.Debugf("convertItemToCells: Skipping unexported field %s in struct %s", fieldMeta.Name, itemType.Name())
continue
}
hasProcessableFields = true // Mark true if we encounter any exported field
jsonTag := fieldMeta.Tag.Get("json")
if jsonTag == "-" {
t.logger.Debugf("convertItemToCells: Skipping field %s in struct %s due to json:\"-\" tag", fieldMeta.Name, itemType.Name())
continue
}
fieldReflectedValue := itemValue.Field(i)
if strings.Contains(jsonTag, ",omitempty") && fieldReflectedValue.IsZero() {
t.logger.Debugf("convertItemToCells: Omitting zero value for field %s in struct %s due to omitempty tag", fieldMeta.Name, itemType.Name())
structCells = append(structCells, "")
continue
}
structCells = append(structCells, t.convertToString(fieldReflectedValue.Interface()))
}
// Only return expanded cells if there were processable fields.
// If a struct has no exported fields, or all were skipped via json:"-",
// it should still produce output (e.g. fmt.Sprintf of the struct) rather than an empty row.
if hasProcessableFields {
t.logger.Debugf("convertItemToCells: Struct %s reflected into %d cells: %v", itemType.Name(), len(structCells), structCells)
return structCells, nil
}
t.logger.Warnf("convertItemToCells: Struct %s has no processable exported fields. Falling back to Sprintf for the whole item (resulting in 1 cell).", itemType.Name())
return []string{t.convertToString(item)}, nil // 'item' is the original potentially pointer type
}
// 5. Item is NOT a struct. It might be a basic type or a non-struct type implementing Formatter/Stringer.
// These should all result in a single cell.
if formatter, ok := item.(tw.Formatter); ok { if formatter, ok := item.(tw.Formatter); ok {
t.logger.Debugf("convertItemToCells: Item (non-struct, type %T) is tw.Formatter. Using Format(). Resulting in 1 cell.", item) t.logger.Debugf("convertItemToCells: Item (non-struct, type %T) is tw.Formatter. Using Format().", item)
return []string{formatter.Format()}, nil return []string{formatter.Format()}, nil
} }
if stringer, ok := item.(fmt.Stringer); ok { if stringer, ok := item.(fmt.Stringer); ok {
t.logger.Debugf("convertItemToCells: Item (non-struct, type %T) is fmt.Stringer. Using String(). Resulting in 1 cell.", item) t.logger.Debugf("convertItemToCells: Item (non-struct, type %T) is fmt.Stringer. Using String().", item)
return []string{stringer.String()}, nil return []string{stringer.String()}, nil
} }
// 6. Fallback for any other single item (e.g., basic types like int, string, bool): t.logger.Debugf("convertItemToCells: Item (type %T) is a basic type. Treating as single cell via convertToString.", item)
t.logger.Debugf("convertItemToCells: Item (type %T) is a basic type or unhandled by other mechanisms. Treating as single cell via convertToString.", item)
return []string{t.convertToString(item)}, nil return []string{t.convertToString(item)}, nil
} }
@@ -1694,3 +1639,92 @@ func (t *Table) updateWidths(row []string, widths tw.Mapper[int, int], padding t
} }
} }
} }
// extractHeadersFromStruct is now a thin wrapper around the new unified function.
// It only cares about the header names.
func (t *Table) extractHeadersFromStruct(sample interface{}) []string {
headers, _ := t.extractFieldsAndValuesFromStruct(sample)
return headers
}
// extractFieldsAndValuesFromStruct is the new single source of truth for struct reflection.
// It recursively processes a struct, handling pointers and embedded structs,
// and returns two slices: one for header names and one for string-converted values.
func (t *Table) extractFieldsAndValuesFromStruct(sample interface{}) ([]string, []string) {
v := reflect.ValueOf(sample)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil, nil
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return nil, nil
}
typ := v.Type()
headers := make([]string, 0, typ.NumField())
values := make([]string, 0, typ.NumField())
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
fieldValue := v.Field(i)
// Skip unexported fields
if field.PkgPath != "" {
continue
}
// Handle embedded structs recursively
if field.Anonymous {
h, val := t.extractFieldsAndValuesFromStruct(fieldValue.Interface())
if h != nil {
headers = append(headers, h...)
values = append(values, val...)
}
continue
}
var tagName string
skipField := false
// Loop through the priority list of configured tags (e.g., ["json", "db"])
for _, tagKey := range t.config.Behavior.Structs.Tags {
tagValue := field.Tag.Get(tagKey)
// If a tag is found...
if tagValue != "" {
// If the tag is "-", this field should be skipped entirely.
if tagValue == "-" {
skipField = true
break // Stop processing tags for this field.
}
// Otherwise, we've found our highest-priority tag. Store it and stop.
tagName = tagValue
break // Stop processing tags for this field.
}
}
// If the field was marked for skipping, continue to the next field.
if skipField {
continue
}
// Determine header name from the tag or fallback to the field name
headerName := field.Name
if tagName != "" {
headerName = strings.Split(tagName, ",")[0]
}
headers = append(headers, tw.Title(headerName))
// Determine value, respecting omitempty from the found tag
value := ""
if !strings.Contains(tagName, ",omitempty") || !fieldValue.IsZero() {
value = t.convertToString(fieldValue.Interface())
}
values = append(values, value)
}
return headers, values
}
+3 -3
View File
@@ -985,15 +985,15 @@ github.com/nxadm/tail/winfile
# github.com/oklog/run v1.2.0 # github.com/oklog/run v1.2.0
## explicit; go 1.20 ## explicit; go 1.20
github.com/oklog/run github.com/oklog/run
# github.com/olekukonko/errors v0.0.0-20250405072817-4e6d85265da6 # github.com/olekukonko/errors v1.1.0
## explicit; go 1.21 ## explicit; go 1.21
github.com/olekukonko/errors github.com/olekukonko/errors
# github.com/olekukonko/ll v0.0.8 # github.com/olekukonko/ll v0.0.9
## explicit; go 1.21 ## explicit; go 1.21
github.com/olekukonko/ll github.com/olekukonko/ll
github.com/olekukonko/ll/lh github.com/olekukonko/ll/lh
github.com/olekukonko/ll/lx github.com/olekukonko/ll/lx
# github.com/olekukonko/tablewriter v1.0.8 # github.com/olekukonko/tablewriter v1.0.9
## explicit; go 1.21 ## explicit; go 1.21
github.com/olekukonko/tablewriter github.com/olekukonko/tablewriter
github.com/olekukonko/tablewriter/pkg/twwarp github.com/olekukonko/tablewriter/pkg/twwarp