diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f71254f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "external/go"] + path = external/go + url = https://github.com/dappcore/go.git + branch = dev diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9ae460a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Agent Notes + +This repository follows the `dappco.re/go` v0.9.0 audit contract. + +- Keep Go source under `go/`. +- Use `dappco.re/go` primitives for assertions, buffers, strings, filesystem, + process, and formatted errors. +- Do not edit `.core/` runtime configuration. +- Do not edit `external/` dependency source. diff --git a/README.md b/README.md new file mode 100644 index 0000000..baba877 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# go-log + +Structured logging and error helpers for the `dappco.re/go` ecosystem. + +The Go module lives in `go/`. From this repository root, run the v0.9.0 audit +with: + +```sh +bash /Users/snider/Code/core/go/tests/cli/v090-upgrade/audit.sh . +``` diff --git a/errors_test.go b/errors_test.go deleted file mode 100644 index 787120f..0000000 --- a/errors_test.go +++ /dev/null @@ -1,794 +0,0 @@ -package log - -import ( - "bytes" - // Note: intrinsic - errors.New/Is/As/Unwrap exercise stdlib error chain behavior; core.E is downstream. - "errors" - // Note: intrinsic - fmt.Errorf builds wrapped test errors; core is downstream of go-log so cannot self-depend. - "fmt" - "slices" - // Note: intrinsic - strings.* assertions inspect rendered errors/logs; core.* helpers are downstream of go-log. - "strings" - "testing" - "time" -) - -// --- Err Type Tests --- - -func TestErr_Error_Good(t *testing.T) { - // With underlying error - err := &Err{Op: "db.Query", Msg: "failed to query", Err: errors.New("connection refused")} - if want, got := "db.Query: failed to query: connection refused", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } - - // With code - err = &Err{Op: "api.Call", Msg: "request failed", Code: "TIMEOUT"} - if want, got := "api.Call: request failed [TIMEOUT]", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } - - // With both underlying error and code - err = &Err{Op: "user.Save", Msg: "save failed", Err: errors.New("duplicate key"), Code: "DUPLICATE"} - if want, got := "user.Save: save failed [DUPLICATE]: duplicate key", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } - - // Just op and msg - err = &Err{Op: "cache.Get", Msg: "miss"} - if want, got := "cache.Get: miss", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestErr_Error_EmptyOp_Good(t *testing.T) { - // No Op - should not have leading colon - err := &Err{Msg: "just a message"} - if want, got := "just a message", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } - - // No Op with code - err = &Err{Msg: "error with code", Code: "ERR_CODE"} - if want, got := "error with code [ERR_CODE]", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } - - // No Op with underlying error - err = &Err{Msg: "wrapped", Err: errors.New("underlying")} - if want, got := "wrapped: underlying", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestErr_Error_EmptyMsg_Good(t *testing.T) { - err := &Err{Op: "api.Call", Code: "TIMEOUT"} - if want, got := "api.Call: [TIMEOUT]", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } - - err = &Err{Op: "api.Call", Err: errors.New("underlying")} - if want, got := "api.Call: underlying", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } - - err = &Err{Op: "api.Call", Code: "TIMEOUT", Err: errors.New("underlying")} - if want, got := "api.Call: [TIMEOUT]: underlying", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } - - err = &Err{Op: "api.Call"} - if want, got := "api.Call", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestErr_Unwrap_Good(t *testing.T) { - underlying := errors.New("underlying error") - err := &Err{Op: "test", Msg: "wrapped", Err: underlying} - - if want, got := underlying, errors.Unwrap(err); want != got { - t.Fatalf("want %v, got %v", want, got) - } - if !errors.Is(err, underlying) { - t.Fatal("expected true") - } -} - -// --- Error Creation Function Tests --- - -func TestE_Good(t *testing.T) { - underlying := errors.New("base error") - err := E("op.Name", "something failed", underlying) - - if err == nil { - t.Fatal("expected non-nil") - } - var logErr *Err - if !errors.As(err, &logErr) { - t.Fatal("expected true") - } - if want, got := "op.Name", logErr.Op; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if want, got := "something failed", logErr.Msg; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if want, got := underlying, logErr.Err; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestE_Good_NilError(t *testing.T) { - // E creates an error even with nil underlying - useful for errors without causes - err := E("op.Name", "message", nil) - if err == nil { - t.Fatal("expected non-nil") - } - if want, got := "op.Name: message", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestEWithRecovery_Good(t *testing.T) { - retryAfter := time.Second * 5 - err := EWithRecovery("op.Name", "message", nil, true, &retryAfter, "retry once") - - var logErr *Err - if err == nil { - t.Fatal("expected non-nil") - } - if !As(err, &logErr) { - t.Fatal("expected true") - } - if !logErr.Retryable { - t.Fatal("expected true") - } - if logErr.RetryAfter == nil { - t.Fatal("expected non-nil") - } - if want, got := retryAfter, *logErr.RetryAfter; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if want, got := "retry once", logErr.NextAction; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestWrap_Good(t *testing.T) { - underlying := errors.New("base") - err := Wrap(underlying, "handler.Process", "processing failed") - - if err == nil { - t.Fatal("expected non-nil") - } - if !strings.Contains(err.Error(), "handler.Process") { - t.Fatalf("expected %q to contain %q", err.Error(), "handler.Process") - } - if !strings.Contains(err.Error(), "processing failed") { - t.Fatalf("expected %q to contain %q", err.Error(), "processing failed") - } - if !errors.Is(err, underlying) { - t.Fatal("expected true") - } -} - -func TestWrap_PreservesCode_Good(t *testing.T) { - // Create an error with a code - inner := WrapCode(errors.New("base"), "VALIDATION_ERROR", "inner.Op", "validation failed") - - // Wrap it - should preserve the code - outer := Wrap(inner, "outer.Op", "outer context") - - if outer == nil { - t.Fatal("expected non-nil") - } - if want, got := "VALIDATION_ERROR", ErrCode(outer); want != got { - t.Fatalf("want %v, got %v", want, got) - } - if !strings.Contains(outer.Error(), "[VALIDATION_ERROR]") { - t.Fatalf("expected %q to contain %q", outer.Error(), "[VALIDATION_ERROR]") - } -} - -func TestWrap_PreservesCode_FromNestedErrWithEmptyOuterCode_Good(t *testing.T) { - inner := WrapCode(errors.New("base"), "VALIDATION_ERROR", "inner.Op", "validation failed") - mid := &Err{Op: "mid.Op", Msg: "mid failed", Err: inner} - - outer := Wrap(mid, "outer.Op", "outer context") - - if outer == nil { - t.Fatal("expected non-nil") - } - if want, got := "VALIDATION_ERROR", ErrCode(outer); want != got { - t.Fatalf("want %v, got %v", want, got) - } - if !strings.Contains(outer.Error(), "[VALIDATION_ERROR]") { - t.Fatalf("expected %q to contain %q", outer.Error(), "[VALIDATION_ERROR]") - } -} - -func TestWrap_PreservesRecovery_Good(t *testing.T) { - retryAfter := 15 * time.Second - inner := &Err{Msg: "inner", Retryable: true, RetryAfter: &retryAfter, NextAction: "inspect input"} - - outer := Wrap(inner, "outer.Op", "outer context") - - if outer == nil { - t.Fatal("expected non-nil") - } - var logErr *Err - if !As(outer, &logErr) { - t.Fatal("expected true") - } - if !logErr.Retryable { - t.Fatal("expected true") - } - if logErr.RetryAfter == nil { - t.Fatal("expected non-nil") - } - if want, got := retryAfter, *logErr.RetryAfter; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if want, got := "inspect input", logErr.NextAction; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestWrap_PreservesCode_FromNestedChain_Good(t *testing.T) { - root := WrapCode(errors.New("base"), "CHAIN_ERROR", "inner", "inner failed") - wrapped := Wrap(fmt.Errorf("mid layer: %w", root), "outer", "outer context") - - if want, got := "CHAIN_ERROR", ErrCode(wrapped); want != got { - t.Fatalf("want %v, got %v", want, got) - } - if !strings.Contains(wrapped.Error(), "[CHAIN_ERROR]") { - t.Fatalf("expected %q to contain %q", wrapped.Error(), "[CHAIN_ERROR]") - } -} - -func TestWrap_NilError_Good(t *testing.T) { - err := Wrap(nil, "op", "msg") - if err != nil { - t.Fatalf("expected nil, got %v", err) - } -} - -func TestWrapCode_Good(t *testing.T) { - underlying := errors.New("validation failed") - err := WrapCode(underlying, "INVALID_INPUT", "api.Validate", "bad request") - - if err == nil { - t.Fatal("expected non-nil") - } - var logErr *Err - if !errors.As(err, &logErr) { - t.Fatal("expected true") - } - if want, got := "INVALID_INPUT", logErr.Code; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if want, got := "api.Validate", logErr.Op; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if !strings.Contains(err.Error(), "[INVALID_INPUT]") { - t.Fatalf("expected %q to contain %q", err.Error(), "[INVALID_INPUT]") - } -} - -func TestWrapCode_Good_EmptyCodeDoesNotInherit(t *testing.T) { - inner := WrapCode(errors.New("base"), "INNER_CODE", "inner.Op", "inner failed") - - outer := WrapCode(inner, "", "outer.Op", "outer failed") - - var logErr *Err - if !As(outer, &logErr) { - t.Fatal("expected true") - } - if want, got := "", logErr.Code; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestWrapCodeWithRecovery_Good(t *testing.T) { - retryAfter := time.Minute - err := WrapCodeWithRecovery(errors.New("validation failed"), "INVALID_INPUT", "api.Validate", "bad request", true, &retryAfter, "retry with backoff") - - var logErr *Err - if err == nil { - t.Fatal("expected non-nil") - } - if !As(err, &logErr) { - t.Fatal("expected true") - } - if !logErr.Retryable { - t.Fatal("expected true") - } - if logErr.RetryAfter == nil { - t.Fatal("expected non-nil") - } - if want, got := retryAfter, *logErr.RetryAfter; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if want, got := "retry with backoff", logErr.NextAction; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if want, got := "INVALID_INPUT", logErr.Code; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestWrapCodeWithRecovery_Good_EmptyCodeDoesNotInherit(t *testing.T) { - retryAfter := time.Minute - inner := WrapCodeWithRecovery(errors.New("validation failed"), "INNER_CODE", "inner.Op", "inner failed", true, &retryAfter, "retry later") - - outer := WrapCodeWithRecovery(inner, "", "outer.Op", "outer failed", true, &retryAfter, "retry later") - - var logErr *Err - if !As(outer, &logErr) { - t.Fatal("expected true") - } - if want, got := "", logErr.Code; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestWrapCode_Good_NilError(t *testing.T) { - // WrapCode with nil error but with code still creates an error - err := WrapCode(nil, "CODE", "op", "msg") - if err == nil { - t.Fatal("expected non-nil") - } - if !strings.Contains(err.Error(), "[CODE]") { - t.Fatalf("expected %q to contain %q", err.Error(), "[CODE]") - } - - // Only returns nil when both error and code are empty - err = WrapCode(nil, "", "op", "msg") - if err != nil { - t.Fatalf("expected nil, got %v", err) - } -} - -func TestNewCode_Good(t *testing.T) { - err := NewCode("NOT_FOUND", "resource not found") - - var logErr *Err - if !errors.As(err, &logErr) { - t.Fatal("expected true") - } - if want, got := "NOT_FOUND", logErr.Code; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if want, got := "resource not found", logErr.Msg; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if logErr.Err != nil { - t.Fatalf("expected nil, got %v", logErr.Err) - } -} - -func TestNewCodeWithRecovery_Good(t *testing.T) { - retryAfter := 2 * time.Minute - err := NewCodeWithRecovery("NOT_FOUND", "resource not found", false, &retryAfter, "contact support") - - var logErr *Err - if err == nil { - t.Fatal("expected non-nil") - } - if !As(err, &logErr) { - t.Fatal("expected true") - } - if logErr.Retryable { - t.Fatal("expected false") - } - if logErr.RetryAfter == nil { - t.Fatal("expected non-nil") - } - if want, got := retryAfter, *logErr.RetryAfter; want != got { - t.Fatalf("want %v, got %v", want, got) - } - if want, got := "contact support", logErr.NextAction; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -// --- Standard Library Wrapper Tests --- - -func TestIs_Good(t *testing.T) { - sentinel := errors.New("sentinel") - wrapped := Wrap(sentinel, "test", "wrapped") - - if !Is(wrapped, sentinel) { - t.Fatal("expected true") - } - if Is(wrapped, errors.New("other")) { - t.Fatal("expected false") - } -} - -func TestAs_Good(t *testing.T) { - err := E("test.Op", "message", errors.New("base")) - - var logErr *Err - if !As(err, &logErr) { - t.Fatal("expected true") - } - if want, got := "test.Op", logErr.Op; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestNewError_Good(t *testing.T) { - err := NewError("simple error") - if err == nil { - t.Fatal("expected non-nil") - } - if want, got := "simple error", err.Error(); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestJoin_Good(t *testing.T) { - err1 := errors.New("error 1") - err2 := errors.New("error 2") - joined := Join(err1, err2) - - if !errors.Is(joined, err1) { - t.Fatal("expected true") - } - if !errors.Is(joined, err2) { - t.Fatal("expected true") - } -} - -// --- Helper Function Tests --- - -func TestOp_Good(t *testing.T) { - err := E("mypackage.MyFunc", "failed", errors.New("cause")) - if want, got := "mypackage.MyFunc", Op(err); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestOp_Good_NotLogError(t *testing.T) { - err := errors.New("plain error") - if want, got := "", Op(err); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestErrCode_Good(t *testing.T) { - err := WrapCode(errors.New("base"), "ERR_CODE", "op", "msg") - if want, got := "ERR_CODE", ErrCode(err); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestErrCode_Good_NoCode(t *testing.T) { - err := E("op", "msg", errors.New("base")) - if want, got := "", ErrCode(err); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestErrCode_Good_PlainError(t *testing.T) { - err := errors.New("plain error") - if want, got := "", ErrCode(err); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestErrCode_Good_Nil(t *testing.T) { - if want, got := "", ErrCode(nil); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestRetryAfter_Good(t *testing.T) { - retryAfter := 42 * time.Second - err := &Err{Msg: "typed", RetryAfter: &retryAfter} - - got, ok := RetryAfter(err) - if !ok { - t.Fatal("expected true") - } - if want, got := retryAfter, *got; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestRetryAfter_Good_NestedChain(t *testing.T) { - retryAfter := 42 * time.Second - inner := &Err{Msg: "typed", RetryAfter: &retryAfter} - outer := &Err{Msg: "outer", Err: inner} - - got, ok := RetryAfter(outer) - if !ok { - t.Fatal("expected true") - } - if want, got := retryAfter, *got; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestIsRetryable_Good(t *testing.T) { - err := &Err{Msg: "typed", Retryable: true} - if !IsRetryable(err) { - t.Fatal("expected true") - } -} - -func TestRecoveryAction_Good(t *testing.T) { - err := &Err{Msg: "typed", NextAction: "inspect"} - if want, got := "inspect", RecoveryAction(err); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestRecoveryAction_Good_NestedChain(t *testing.T) { - inner := &Err{Msg: "typed", NextAction: "inspect"} - outer := &Err{Msg: "outer", Err: inner} - - if want, got := "inspect", RecoveryAction(outer); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestMessage_Good(t *testing.T) { - err := E("op", "the message", errors.New("base")) - if want, got := "the message", Message(err); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestMessage_Good_PlainError(t *testing.T) { - err := errors.New("plain message") - if want, got := "plain message", Message(err); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestMessage_Good_Nil(t *testing.T) { - if want, got := "", Message(nil); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestRoot_Good(t *testing.T) { - root := errors.New("root cause") - level1 := Wrap(root, "level1", "wrapped once") - level2 := Wrap(level1, "level2", "wrapped twice") - - if want, got := root, Root(level2); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestRoot_Good_SingleError(t *testing.T) { - err := errors.New("single") - if want, got := err, Root(err); want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestRoot_Good_Nil(t *testing.T) { - if got := Root(nil); got != nil { - t.Fatalf("expected nil, got %v", got) - } -} - -// --- Log-and-Return Helper Tests --- - -func TestLogError_Good(t *testing.T) { - // Capture log output - var buf bytes.Buffer - logger := New(Options{Level: LevelDebug, Output: &buf}) - SetDefault(logger) - defer SetDefault(New(Options{Level: LevelInfo})) - - underlying := errors.New("connection failed") - err := LogError(underlying, "db.Connect", "database unavailable") - - // Check returned error - if err == nil { - t.Fatal("expected non-nil") - } - if !strings.Contains(err.Error(), "db.Connect") { - t.Fatalf("expected %q to contain %q", err.Error(), "db.Connect") - } - if !strings.Contains(err.Error(), "database unavailable") { - t.Fatalf("expected %q to contain %q", err.Error(), "database unavailable") - } - if !errors.Is(err, underlying) { - t.Fatal("expected true") - } - - // Check log output - output := buf.String() - if !strings.Contains(output, "[ERR]") { - t.Fatalf("expected %q to contain %q", output, "[ERR]") - } - if !strings.Contains(output, "database unavailable") { - t.Fatalf("expected %q to contain %q", output, "database unavailable") - } - if !strings.Contains(output, "op=\"db.Connect\"") { - t.Fatalf("expected %q to contain %q", output, "op=\"db.Connect\"") - } -} - -func TestLogError_Good_LogsOriginalErrorContext(t *testing.T) { - var buf bytes.Buffer - logger := New(Options{Level: LevelDebug, Output: &buf}) - SetDefault(logger) - defer SetDefault(New(Options{Level: LevelInfo})) - - underlying := E("db.Query", "query failed", errors.New("timeout")) - err := LogError(underlying, "db.Connect", "database unavailable") - - if err == nil { - t.Fatal("expected non-nil") - } - - output := buf.String() - if !strings.Contains(output, "op=\"db.Connect\"") { - t.Fatalf("expected %q to contain %q", output, "op=\"db.Connect\"") - } - if !strings.Contains(output, "stack=\"db.Query\"") { - t.Fatalf("expected %q to contain %q", output, "stack=\"db.Query\"") - } - if strings.Contains(output, "stack=\"db.Connect -> db.Query\"") { - t.Fatalf("expected %q not to contain %q", output, "stack=\"db.Connect -> db.Query\"") - } -} - -func TestLogError_Good_NilError(t *testing.T) { - var buf bytes.Buffer - logger := New(Options{Level: LevelDebug, Output: &buf}) - SetDefault(logger) - defer SetDefault(New(Options{Level: LevelInfo})) - - err := LogError(nil, "op", "msg") - if err != nil { - t.Fatalf("expected nil, got %v", err) - } - if got := buf.String(); got != "" { - t.Fatalf("expected empty, got %v", got) - } -} - -func TestLogWarn_Good(t *testing.T) { - var buf bytes.Buffer - logger := New(Options{Level: LevelDebug, Output: &buf}) - SetDefault(logger) - defer SetDefault(New(Options{Level: LevelInfo})) - - underlying := errors.New("cache miss") - err := LogWarn(underlying, "cache.Get", "falling back to db") - - if err == nil { - t.Fatal("expected non-nil") - } - if !errors.Is(err, underlying) { - t.Fatal("expected true") - } - - output := buf.String() - if !strings.Contains(output, "[WRN]") { - t.Fatalf("expected %q to contain %q", output, "[WRN]") - } - if !strings.Contains(output, "falling back to db") { - t.Fatalf("expected %q to contain %q", output, "falling back to db") - } -} - -func TestLogWarn_Good_NilError(t *testing.T) { - var buf bytes.Buffer - logger := New(Options{Level: LevelDebug, Output: &buf}) - SetDefault(logger) - defer SetDefault(New(Options{Level: LevelInfo})) - - err := LogWarn(nil, "op", "msg") - if err != nil { - t.Fatalf("expected nil, got %v", err) - } - if got := buf.String(); got != "" { - t.Fatalf("expected empty, got %v", got) - } -} - -func TestMust_Good_NoError(t *testing.T) { - // Should not panic when error is nil - func() { - defer func() { - if got := recover(); got != nil { - t.Fatalf("unexpected panic: %v", got) - } - }() - Must(nil, "test", "should not panic") - }() -} - -func TestMust_Ugly_Panics(t *testing.T) { - var buf bytes.Buffer - logger := New(Options{Level: LevelDebug, Output: &buf}) - SetDefault(logger) - defer SetDefault(New(Options{Level: LevelInfo})) - - didPanic := false - func() { - defer func() { - if recover() != nil { - didPanic = true - } - }() - Must(errors.New("fatal error"), "startup", "initialization failed") - }() - if !didPanic { - t.Fatal("expected panic") - } - - // Verify error was logged before panic - output := buf.String() - if !(strings.Contains(output, "[ERR]") || len(output) > 0) { - t.Fatal("expected true") - } -} - -func TestStackTrace_Good(t *testing.T) { - // Nested operations - err := E("op1", "msg1", nil) - err = Wrap(err, "op2", "msg2") - err = Wrap(err, "op3", "msg3") - - stack := StackTrace(err) - if want, got := []string{"op3", "op2", "op1"}, stack; !slices.Equal(want, got) { - t.Fatalf("want %v, got %v", want, got) - } - - // Format - formatted := FormatStackTrace(err) - if want, got := "op3 -> op2 -> op1", formatted; want != got { - t.Fatalf("want %v, got %v", want, got) - } -} - -func TestStackTrace_Bad_PlainError(t *testing.T) { - err := errors.New("plain error") - if got := StackTrace(err); len(got) != 0 { - t.Fatalf("expected empty, got %v", got) - } - if got := FormatStackTrace(err); got != "" { - t.Fatalf("expected empty, got %v", got) - } -} - -func TestStackTrace_Bad_Nil(t *testing.T) { - if got := StackTrace(nil); len(got) != 0 { - t.Fatalf("expected empty, got %v", got) - } - if got := FormatStackTrace(nil); got != "" { - t.Fatalf("expected empty, got %v", got) - } -} - -func TestStackTrace_Bad_NoOp(t *testing.T) { - err := &Err{Msg: "no op"} - if got := StackTrace(err); len(got) != 0 { - t.Fatalf("expected empty, got %v", got) - } - if got := FormatStackTrace(err); got != "" { - t.Fatalf("expected empty, got %v", got) - } -} - -func TestStackTrace_Mixed_Good(t *testing.T) { - err := E("inner", "msg", nil) - err = fmt.Errorf("wrapper: %w", err) - err = Wrap(err, "outer", "msg") - - stack := StackTrace(err) - if want, got := []string{"outer", "inner"}, stack; !slices.Equal(want, got) { - t.Fatalf("want %v, got %v", want, got) - } -} diff --git a/external/go b/external/go new file mode 160000 index 0000000..d661b70 --- /dev/null +++ b/external/go @@ -0,0 +1 @@ +Subproject commit d661b703e16183b3cbab101de189f688888a1174 diff --git a/go.sum b/go.sum deleted file mode 100644 index e69de29..0000000 diff --git a/go.work b/go.work new file mode 100644 index 0000000..7edd93c --- /dev/null +++ b/go.work @@ -0,0 +1,14 @@ +go 1.26.2 + +// Workspace mode for development: pulls fresh code from external/ submodules. +// +// Devs: git clone --recursive → go.work picks up local sources, latest dev. +// CI: GOWORK=off → go.mod tags drive resolution, reproducible. +// +// Submodule pins live in .gitmodules + the recorded SHA per submodule entry. +// Bump a single dep: git submodule update --remote external/ + +use ( + ./go + ./external/go +) diff --git a/errors.go b/go/errors.go similarity index 55% rename from errors.go rename to go/errors.go index 2494c0d..ac53bab 100644 --- a/errors.go +++ b/go/errors.go @@ -1,18 +1,11 @@ -// Package log provides structured logging and error handling for Core applications. -// -// This file implements structured error types and combined log-and-return helpers -// that simplify common error handling patterns. - -package log +// Package golog provides structured logging and error handling for Core applications. +package golog import ( - // AX-6 circular-dependency exception: dappco.re/go/core imports go-log to - // expose core.E, core.Is, and core.As, so go-log cannot import core without a - // dependency cycle. These stdlib imports are structural error primitives. - "errors" "iter" - "strings" "time" + + core "dappco.re/go" ) type singleUnwrapper interface { @@ -24,12 +17,11 @@ type multiUnwrapper interface { } // Err represents a structured error with operational context. -// It implements the error interface and supports unwrapping. type Err struct { - Op string // Operation being performed (e.g., "user.Save") + Op string // Operation being performed (for example, "user.Save") Msg string // Human-readable message - Err error // Underlying error (optional) - Code string // Error code (optional, e.g., "VALIDATION_FAILED") + Err error // Underlying error, when present + Code string // Error code, when present // Retryable indicates whether the caller can safely retry this error. Retryable bool // RetryAfter suggests a delay before retrying when Retryable is true. @@ -47,15 +39,15 @@ func (e *Err) Error() string { body := e.Msg if body == "" { if e.Code != "" { - body = "[" + e.Code + "]" + body = core.Concat("[", e.Code, "]") } } else if e.Code != "" { - body += " [" + e.Code + "]" + body = core.Concat(body, " [", e.Code, "]") } if e.Err != nil { if body != "" { - body += ": " + e.Err.Error() + body = core.Concat(body, ": ", e.Err.Error()) } else { body = e.Err.Error() } @@ -63,35 +55,30 @@ func (e *Err) Error() string { if e.Op != "" { if body != "" { - return e.Op + ": " + body + return core.Concat(e.Op, ": ", body) } return e.Op } return body } -// Unwrap returns the underlying error for use with errors.Is and errors.As. -func (e *Err) Unwrap() error { +// Unwrap returns the underlying error for standard error-chain inspection. +func (e *Err) Unwrap() ( + err error, +) { + if e == nil { + return nil + } return e.Err } -// --- Error Creation Functions --- - // E creates a new Err with operation context. -// The underlying error can be nil for creating errors without a cause. -// -// Example: -// -// return log.E("user.Save", "failed to save user", err) -// return log.E("api.Call", "rate limited", nil) // No underlying cause -func E(op, msg string, err error) error { - return &Err{Op: op, Msg: msg, Err: err} +func E(op, msg string, err error) core.Result { + return core.Ok(&Err{Op: op, Msg: msg, Err: err}) } // EWithRecovery creates a new Err with operation context and recovery metadata. -// -// return log.EWithRecovery("api.Call", "temporary failure", err, true, &retryAfter, "retry with backoff") -func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) error { +func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.Duration, nextAction string) core.Result { recoveryErr := &Err{ Op: op, Msg: msg, @@ -101,31 +88,23 @@ func EWithRecovery(op, msg string, err error, retryable bool, retryAfter *time.D recoveryErr.Retryable = retryable recoveryErr.RetryAfter = retryAfter recoveryErr.NextAction = nextAction - return recoveryErr + return core.Ok(recoveryErr) } // Wrap wraps an error with operation context. -// Returns nil if err is nil, to support conditional wrapping. -// Preserves error Code if the wrapped error is an *Err. -// -// Example: -// -// return log.Wrap(err, "db.Query", "database query failed") -func Wrap(err error, op, msg string) error { +func Wrap(err error, op, msg string) core.Result { if err == nil { - return nil + return core.Ok(nil) } wrapped := &Err{Op: op, Msg: msg, Err: err, Code: inheritedCode(err)} inheritRecovery(wrapped, err) - return wrapped + return core.Ok(wrapped) } // WrapWithRecovery wraps an error with operation context and explicit recovery metadata. -// -// return log.WrapWithRecovery(err, "api.Call", "temporary failure", true, &retryAfter, "retry with backoff") -func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { +func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) core.Result { if err == nil { - return nil + return core.Ok(nil) } recoveryErr := &Err{ Op: op, @@ -137,31 +116,23 @@ func WrapWithRecovery(err error, op, msg string, retryable bool, retryAfter *tim recoveryErr.Retryable = retryable recoveryErr.RetryAfter = retryAfter recoveryErr.NextAction = nextAction - return recoveryErr + return core.Ok(recoveryErr) } // WrapCode wraps an error with operation context and error code. -// Returns nil only if both err is nil AND code is empty. -// Useful for API errors that need machine-readable codes. -// -// Example: -// -// return log.WrapCode(err, "VALIDATION_ERROR", "user.Validate", "invalid email") -func WrapCode(err error, code, op, msg string) error { +func WrapCode(err error, code, op, msg string) core.Result { if err == nil && code == "" { - return nil + return core.Ok(nil) } wrapped := &Err{Op: op, Msg: msg, Err: err, Code: code} inheritRecovery(wrapped, err) - return wrapped + return core.Ok(wrapped) } // WrapCodeWithRecovery wraps an error with operation context, code, and recovery metadata. -// -// return log.WrapCodeWithRecovery(err, "TEMPORARY_UNAVAILABLE", "api.Call", "temporary failure", true, &retryAfter, "retry with backoff") -func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { +func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retryAfter *time.Duration, nextAction string) core.Result { if err == nil && code == "" { - return nil + return core.Ok(nil) } recoveryErr := &Err{ Op: op, @@ -173,34 +144,29 @@ func WrapCodeWithRecovery(err error, code, op, msg string, retryable bool, retry recoveryErr.Retryable = retryable recoveryErr.RetryAfter = retryAfter recoveryErr.NextAction = nextAction - return recoveryErr + return core.Ok(recoveryErr) } -// NewCode creates an error with just code and message (no underlying error). -// Useful for creating sentinel errors with codes. -// -// Example: -// -// var ErrNotFound = log.NewCode("NOT_FOUND", "resource not found") -func NewCode(code, msg string) error { - return &Err{Msg: msg, Code: code} +// NewCode creates an error with just code and message. +func NewCode(code, msg string) core.Result { + return core.Ok(&Err{Msg: msg, Code: code}) } // NewCodeWithRecovery creates a coded error with recovery metadata. -// -// var ErrTemporary = log.NewCodeWithRecovery("TEMPORARY_UNAVAILABLE", "temporary failure", true, &retryAfter, "retry with backoff") -func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) error { - return &Err{ +func NewCodeWithRecovery(code, msg string, retryable bool, retryAfter *time.Duration, nextAction string) core.Result { + return core.Ok(&Err{ Msg: msg, Code: code, Retryable: retryable, RetryAfter: retryAfter, NextAction: nextAction, - } + }) } -// inheritRecovery copies recovery metadata from the first *Err in err's chain. -func inheritRecovery(dst *Err, err error) { +func inheritRecovery( + dst *Err, + err error, +) { if err == nil || dst == nil { return } @@ -219,7 +185,6 @@ func inheritRecovery(dst *Err, err error) { }) } -// inheritedCode returns the first non-empty code found in an error chain. func inheritedCode(err error) string { var code string walkErrorTree(err, func(current error) bool { @@ -234,8 +199,6 @@ func inheritedCode(err error) string { } // RetryAfter returns the first retry-after hint from an error chain, if present. -// -// retryAfter, ok := log.RetryAfter(err) func RetryAfter(err error) (*time.Duration, bool) { var retryAfter *time.Duration var ok bool @@ -252,8 +215,6 @@ func RetryAfter(err error) (*time.Duration, bool) { } // IsRetryable reports whether the error chain contains a retryable Err. -// -// if log.IsRetryable(err) { /* retry the operation */ } func IsRetryable(err error) bool { var retryable bool walkErrorTree(err, func(current error) bool { @@ -268,8 +229,6 @@ func IsRetryable(err error) bool { } // RecoveryAction returns the first next action from an error chain. -// -// next := log.RecoveryAction(err) func RecoveryAction(err error) string { var nextAction string walkErrorTree(err, func(current error) bool { @@ -287,47 +246,27 @@ func retryableHint(err error) bool { return IsRetryable(err) } -// --- Standard Library Wrappers --- - // Is reports whether any error in err's tree matches target. -// Wrapper around errors.Is for convenience. -// -// if log.Is(err, context.DeadlineExceeded) { /* handle timeout */ } func Is(err, target error) bool { - return errors.Is(err, target) + return core.Is(err, target) } // As finds the first error in err's tree that matches target. -// Wrapper around errors.As for convenience. -// -// var e *log.Err -// if log.As(err, &e) { /* use e.Code */ } func As(err error, target any) bool { - return errors.As(err, target) + return core.As(err, target) } // NewError creates a simple error with the given text. -// Wrapper around errors.New for convenience. -// -// return log.NewError("invalid state") -func NewError(text string) error { - return errors.New(text) +func NewError(text string) core.Result { + return core.Ok(&Err{Msg: text}) } // Join combines multiple errors into one. -// Wrapper around errors.Join for convenience. -// -// return log.Join(validateErr, persistErr) -func Join(errs ...error) error { - return errors.Join(errs...) +func Join(errs ...error) core.Result { + return core.Ok(core.ErrorJoin(errs...)) } -// --- Error Introspection Helpers --- - // Op extracts the operation name from an error. -// Returns empty string if the error is not an *Err. -// -// op := log.Op(err) // e.g. "user.Save" func Op(err error) string { var op string walkErrorTree(err, func(current error) bool { @@ -342,17 +281,11 @@ func Op(err error) string { } // ErrCode extracts the error code from an error. -// Returns empty string if the error is not an *Err or has no code. -// -// code := log.ErrCode(err) // e.g. "VALIDATION_FAILED" func ErrCode(err error) string { return inheritedCode(err) } // Message extracts the message from an error. -// Returns the error's Error() string if not an *Err. -// -// msg := log.Message(err) func Message(err error) string { if err == nil { return "" @@ -373,35 +306,29 @@ func Message(err error) string { } // Root returns the root cause of an error chain. -// Unwraps until no more wrapped errors are found. -// -// cause := log.Root(err) -func Root(err error) error { +func Root(err error) core.Result { if err == nil { - return nil + return core.Ok(nil) } switch current := any(err).(type) { case multiUnwrapper: children := current.Unwrap() if len(children) == 0 { - return err + return core.Ok(err) } return Root(children[0]) case singleUnwrapper: unwrapped := current.Unwrap() if unwrapped == nil { - return err + return core.Ok(err) } return Root(unwrapped) default: - return err + return core.Ok(err) } } // AllOps returns an iterator over all operational contexts in the error chain. -// It traverses the error tree using errors.Unwrap. -// -// for op := range log.AllOps(err) { /* "api.Call" → "db.Query" → ... */ } func AllOps(err error) iter.Seq[string] { return func(yield func(string) bool) { walkErrorTree(err, func(current error) bool { @@ -413,10 +340,7 @@ func AllOps(err error) iter.Seq[string] { } } -// StackTrace returns the logical stack trace (chain of operations) from an error. -// It returns an empty slice if no operational context is found. -// -// ops := log.StackTrace(err) // ["api.Call", "db.Query", "sql.Exec"] +// StackTrace returns the logical stack trace from an error. func StackTrace(err error) []string { var stack []string for op := range AllOps(err) { @@ -426,8 +350,6 @@ func StackTrace(err error) []string { } // FormatStackTrace returns a pretty-printed logical stack trace. -// -// trace := log.FormatStackTrace(err) // "api.Call -> db.Query -> sql.Exec" func FormatStackTrace(err error) string { var ops []string for op := range AllOps(err) { @@ -436,29 +358,13 @@ func FormatStackTrace(err error) string { if len(ops) == 0 { return "" } - return strings.Join(ops, " -> ") -} - -// --- Combined Log-and-Return Helpers --- - -// LogError logs an error at Error level and returns a wrapped error. -// Reduces boilerplate in error handling paths. -// -// Example: -// -// // Before -// if err != nil { -// log.Error("failed to save", "err", err) -// return errors.Wrap(err, "user.Save", "failed to save") -// } -// -// // After -// if err != nil { -// return log.LogError(err, "user.Save", "failed to save") -// } -func LogError(err error, op, msg string) error { + return core.Join(" -> ", ops...) +} + +// LogError logs at Error level and returns a wrapped error result. +func LogError(err error, op, msg string) core.Result { if err == nil { - return nil + return core.Ok(nil) } wrapped := Wrap(err, op, msg) Default().Error(msg, "op", op, "err", err) @@ -486,15 +392,10 @@ func (e *Err) hasRecovery() bool { return e != nil && (e.Retryable || e.RetryAfter != nil || e.NextAction != "") } -// LogWarn logs at Warn level and returns a wrapped error. -// Use for recoverable errors that should be logged but not treated as critical. -// -// Example: -// -// return log.LogWarn(err, "cache.Get", "cache miss, falling back to db") -func LogWarn(err error, op, msg string) error { +// LogWarn logs at Warn level and returns a wrapped error result. +func LogWarn(err error, op, msg string) core.Result { if err == nil { - return nil + return core.Ok(nil) } wrapped := Wrap(err, op, msg) Default().Warn(msg, "op", op, "err", err) @@ -502,15 +403,10 @@ func LogWarn(err error, op, msg string) error { } // Must panics if err is not nil, logging first. -// Use for errors that should never happen and indicate programmer error. -// -// Example: -// -// log.Must(Initialize(), "app", "startup failed") func Must(err error, op, msg string) { if err != nil { wrapped := Wrap(err, op, msg) Default().Error(msg, "op", op, "err", err) - panic(wrapped) + panic(wrapped.Value) } } diff --git a/go/errors_example_test.go b/go/errors_example_test.go new file mode 100644 index 0000000..c03568d --- /dev/null +++ b/go/errors_example_test.go @@ -0,0 +1,222 @@ +package golog_test + +import ( + "time" + + core "dappco.re/go" + golog "dappco.re/go/log" +) + +func ExampleErr_Error() { + err := &golog.Err{Op: "agent.Run", Msg: "failed", Err: core.NewError("root")} + if err.Error() == "" { + panic("missing message") + } +} + +func ExampleErr_Unwrap() { + root := core.NewError("root") + err := &golog.Err{Err: root} + if err.Unwrap() != root { + panic("missing cause") + } +} + +func ExampleE() { + err := core.MustCast[error](golog.E("agent.Run", "failed", nil)) + if golog.Op(err) != "agent.Run" { + panic("missing op") + } +} + +func ExampleEWithRecovery() { + delay := time.Second + err := core.MustCast[error](golog.EWithRecovery("agent.Run", "temporary", nil, true, &delay, "retry")) + if !golog.IsRetryable(err) { + panic("missing retry") + } +} + +func ExampleWrap() { + root := core.NewError("root") + err := core.MustCast[error](golog.Wrap(root, "agent.Run", "failed")) + if !golog.Is(err, root) { + panic("missing cause") + } +} + +func ExampleWrapWithRecovery() { + delay := time.Second + root := core.NewError("root") + err := core.MustCast[error](golog.WrapWithRecovery(root, "agent.Run", "temporary", true, &delay, "retry")) + if golog.RecoveryAction(err) != "retry" { + panic("missing action") + } +} + +func ExampleWrapCode() { + root := core.NewError("root") + err := core.MustCast[error](golog.WrapCode(root, "agent.failed", "agent.Run", "failed")) + if golog.ErrCode(err) != "agent.failed" { + panic("missing code") + } +} + +func ExampleWrapCodeWithRecovery() { + delay := time.Second + err := core.MustCast[error](golog.WrapCodeWithRecovery(nil, "agent.retry", "agent.Run", "temporary", true, &delay, "retry")) + if golog.ErrCode(err) != "agent.retry" { + panic("missing code") + } +} + +func ExampleNewCode() { + err := core.MustCast[error](golog.NewCode("agent.failed", "failed")) + if golog.ErrCode(err) != "agent.failed" { + panic("missing code") + } +} + +func ExampleNewCodeWithRecovery() { + delay := time.Second + err := core.MustCast[error](golog.NewCodeWithRecovery("agent.retry", "retry", true, &delay, "retry")) + if !golog.IsRetryable(err) { + panic("missing retry") + } +} + +func ExampleRetryAfter() { + delay := time.Second + err := core.MustCast[error](golog.NewCodeWithRecovery("agent.retry", "retry", true, &delay, "retry")) + got, ok := golog.RetryAfter(err) + if !ok || *got != delay { + panic("missing delay") + } +} + +func ExampleIsRetryable() { + err := core.MustCast[error](golog.NewCodeWithRecovery("agent.retry", "retry", true, nil, "retry")) + if !golog.IsRetryable(err) { + panic("missing retry") + } +} + +func ExampleRecoveryAction() { + err := core.MustCast[error](golog.NewCodeWithRecovery("agent.retry", "retry", true, nil, "inspect")) + if golog.RecoveryAction(err) != "inspect" { + panic("missing action") + } +} + +func ExampleIs() { + root := core.NewError("root") + err := core.MustCast[error](golog.Wrap(root, "agent.Run", "failed")) + if !golog.Is(err, root) { + panic("missing match") + } +} + +func ExampleAs() { + err := core.MustCast[error](golog.E("agent.Run", "failed", nil)) + var typed *golog.Err + if !golog.As(err, &typed) { + panic("missing typed") + } +} + +func ExampleNewError() { + err := core.MustCast[error](golog.NewError("simple")) + if err.Error() != "simple" { + panic("missing message") + } +} + +func ExampleJoin() { + left := core.NewError("left") + right := core.NewError("right") + err := core.MustCast[error](golog.Join(left, right)) + if !golog.Is(err, left) || !golog.Is(err, right) { + panic("missing join") + } +} + +func ExampleOp() { + err := core.MustCast[error](golog.E("agent.Run", "failed", nil)) + if golog.Op(err) != "agent.Run" { + panic("missing op") + } +} + +func ExampleErrCode() { + err := core.MustCast[error](golog.NewCode("agent.failed", "failed")) + if golog.ErrCode(err) != "agent.failed" { + panic("missing code") + } +} + +func ExampleMessage() { + err := core.MustCast[error](golog.E("agent.Run", "failed", nil)) + if golog.Message(err) != "failed" { + panic("missing message") + } +} + +func ExampleRoot() { + root := core.NewError("root") + err := core.MustCast[error](golog.Wrap(root, "agent.Run", "failed")) + got := core.MustCast[error](golog.Root(err)) + if got != root { + panic("missing root") + } +} + +func ExampleAllOps() { + err := core.MustCast[error](golog.Wrap(core.MustCast[error](golog.E("inner", "failed", nil)), "outer", "failed")) + count := 0 + for range golog.AllOps(err) { + count++ + } + if count != 2 { + panic("missing ops") + } +} + +func ExampleStackTrace() { + err := core.MustCast[error](golog.Wrap(core.MustCast[error](golog.E("inner", "failed", nil)), "outer", "failed")) + if len(golog.StackTrace(err)) != 2 { + panic("missing stack") + } +} + +func ExampleFormatStackTrace() { + err := core.MustCast[error](golog.Wrap(core.MustCast[error](golog.E("inner", "failed", nil)), "outer", "failed")) + if golog.FormatStackTrace(err) != "outer -> inner" { + panic("missing stack") + } +} + +func ExampleLogError() { + original := golog.Default() + buf := core.NewBuffer() + golog.SetDefault(golog.New(golog.Options{Level: golog.LevelDebug, Output: buf})) + defer golog.SetDefault(original) + err := core.MustCast[error](golog.LogError(core.NewError("root"), "agent.Run", "failed")) + if err == nil || !core.Contains(buf.String(), "[ERR]") { + panic("missing failure") + } +} + +func ExampleLogWarn() { + original := golog.Default() + buf := core.NewBuffer() + golog.SetDefault(golog.New(golog.Options{Level: golog.LevelDebug, Output: buf})) + defer golog.SetDefault(original) + err := core.MustCast[error](golog.LogWarn(core.NewError("root"), "agent.Run", "warn")) + if err == nil || !core.Contains(buf.String(), "[WRN]") { + panic("missing warn") + } +} + +func ExampleMust() { + golog.Must(nil, "agent.Run", "ready") +} diff --git a/go/errors_test.go b/go/errors_test.go new file mode 100644 index 0000000..86c3f8e --- /dev/null +++ b/go/errors_test.go @@ -0,0 +1,588 @@ +package golog + +import ( + "time" + + core "dappco.re/go" +) + +func mustError(t *core.T, r core.Result) error { + t.Helper() + core.RequireTrue(t, r.OK) + if r.Value == nil { + return nil + } + err, ok := r.Value.(error) + core.RequireTrue(t, ok) + return err +} + +func TestErrors_Err_Error_Good(t *core.T) { + err := &Err{Op: "agent.Dispatch", Msg: "failed", Err: mustError(t, NewError("root")), Code: "agent.failed"} + got := err.Error() + core.AssertEqual(t, "agent.Dispatch: failed [agent.failed]: root", got) +} + +func TestErrors_Err_Error_Bad(t *core.T) { + err := &Err{} + got := err.Error() + core.AssertEqual(t, "", got) +} + +func TestErrors_Err_Error_Ugly(t *core.T) { + var err *Err + got := err.Error() + core.AssertEqual(t, "", got) +} + +func TestErrors_Err_Unwrap_Good(t *core.T) { + root := mustError(t, NewError("root")) + err := &Err{Err: root} + got := err.Unwrap() + core.AssertEqual(t, root, got) +} + +func TestErrors_Err_Unwrap_Bad(t *core.T) { + err := &Err{} + got := err.Unwrap() + core.AssertNil(t, got) +} + +func TestErrors_Err_Unwrap_Ugly(t *core.T) { + root := mustError(t, NewError("root")) + err := &Err{Err: mustError(t, Wrap(root, "outer", "failed"))} + core.AssertTrue(t, Is(err.Unwrap(), root)) +} + +func TestErrors_E_Good(t *core.T) { + root := mustError(t, NewError("root")) + err := mustError(t, E("agent.Dispatch", "failed", root)) + core.AssertTrue(t, Is(err, root)) + core.AssertEqual(t, "agent.Dispatch", Op(err)) +} + +func TestErrors_E_Bad(t *core.T) { + err := mustError(t, E("", "", nil)) + got := err.Error() + core.AssertEqual(t, "", got) +} + +func TestErrors_E_Ugly(t *core.T) { + err := mustError(t, E("agent.Dispatch", "", mustError(t, NewError("root")))) + got := err.Error() + core.AssertEqual(t, "agent.Dispatch: root", got) +} + +func TestErrors_EWithRecovery_Good(t *core.T) { + retryAfter := 5 * time.Second + err := mustError(t, EWithRecovery("agent.Dispatch", "retry", nil, true, &retryAfter, "retry later")) + core.AssertTrue(t, IsRetryable(err)) + core.AssertEqual(t, "retry later", RecoveryAction(err)) +} + +func TestErrors_EWithRecovery_Bad(t *core.T) { + err := mustError(t, EWithRecovery("agent.Dispatch", "permanent", nil, false, nil, "")) + got, ok := RetryAfter(err) + core.AssertFalse(t, IsRetryable(err)) + core.AssertFalse(t, ok) + core.AssertNil(t, got) +} + +func TestErrors_EWithRecovery_Ugly(t *core.T) { + retryAfter := 10 * time.Millisecond + err := mustError(t, EWithRecovery("", "", mustError(t, NewError("root")), true, &retryAfter, "")) + got, ok := RetryAfter(err) + core.AssertTrue(t, ok) + core.AssertEqual(t, retryAfter, *got) +} + +func TestErrors_Wrap_Good(t *core.T) { + root := mustError(t, NewError("root")) + err := mustError(t, Wrap(root, "agent.Dispatch", "failed")) + core.AssertTrue(t, Is(err, root)) + core.AssertEqual(t, "agent.Dispatch", Op(err)) +} + +func TestErrors_Wrap_Bad(t *core.T) { + err := mustError(t, Wrap(nil, "agent.Dispatch", "failed")) + core.AssertNil(t, err) + core.AssertNil(t, mustError(t, Root(err))) +} + +func TestErrors_Wrap_Ugly(t *core.T) { + inner := mustError(t, NewCode("agent.failed", "root")) + err := mustError(t, Wrap(inner, "agent.Dispatch", "failed")) + core.AssertEqual(t, "agent.failed", ErrCode(err)) +} + +func TestErrors_WrapWithRecovery_Good(t *core.T) { + retryAfter := time.Second + err := mustError(t, WrapWithRecovery(mustError(t, NewError("root")), "agent.Dispatch", "failed", true, &retryAfter, "retry")) + core.AssertTrue(t, IsRetryable(err)) + core.AssertEqual(t, "retry", RecoveryAction(err)) +} + +func TestErrors_WrapWithRecovery_Bad(t *core.T) { + err := mustError(t, WrapWithRecovery(nil, "agent.Dispatch", "failed", true, nil, "retry")) + core.AssertNil(t, err) + core.AssertFalse(t, IsRetryable(err)) +} + +func TestErrors_WrapWithRecovery_Ugly(t *core.T) { + innerDelay := time.Second + outerDelay := 2 * time.Second + inner := mustError(t, NewCodeWithRecovery("inner", "root", true, &innerDelay, "inner")) + err := mustError(t, WrapWithRecovery(inner, "outer", "failed", false, &outerDelay, "outer")) + got, ok := RetryAfter(err) + core.AssertTrue(t, ok) + core.AssertEqual(t, outerDelay, *got) +} + +func TestErrors_WrapCode_Good(t *core.T) { + root := mustError(t, NewError("root")) + err := mustError(t, WrapCode(root, "agent.failed", "agent.Dispatch", "failed")) + core.AssertEqual(t, "agent.failed", ErrCode(err)) + core.AssertTrue(t, Is(err, root)) +} + +func TestErrors_WrapCode_Bad(t *core.T) { + err := mustError(t, WrapCode(nil, "", "agent.Dispatch", "failed")) + core.AssertNil(t, err) + core.AssertEqual(t, "", ErrCode(err)) +} + +func TestErrors_WrapCode_Ugly(t *core.T) { + err := mustError(t, WrapCode(nil, "agent.failed", "agent.Dispatch", "failed")) + core.AssertNotNil(t, err) + core.AssertEqual(t, "agent.failed", ErrCode(err)) +} + +func TestErrors_WrapCodeWithRecovery_Good(t *core.T) { + retryAfter := time.Second + err := mustError(t, WrapCodeWithRecovery(mustError(t, NewError("root")), "agent.failed", "agent.Dispatch", "failed", true, &retryAfter, "retry")) + core.AssertEqual(t, "agent.failed", ErrCode(err)) + core.AssertTrue(t, IsRetryable(err)) +} + +func TestErrors_WrapCodeWithRecovery_Bad(t *core.T) { + err := mustError(t, WrapCodeWithRecovery(nil, "", "agent.Dispatch", "failed", true, nil, "retry")) + core.AssertNil(t, err) + core.AssertEqual(t, "", RecoveryAction(err)) +} + +func TestErrors_WrapCodeWithRecovery_Ugly(t *core.T) { + retryAfter := 3 * time.Second + err := mustError(t, WrapCodeWithRecovery(nil, "agent.failed", "", "", true, &retryAfter, "")) + got, ok := RetryAfter(err) + core.AssertTrue(t, ok) + core.AssertEqual(t, retryAfter, *got) +} + +func TestErrors_NewCode_Good(t *core.T) { + err := mustError(t, NewCode("agent.failed", "dispatch failed")) + core.AssertEqual(t, "agent.failed", ErrCode(err)) + core.AssertEqual(t, "dispatch failed", Message(err)) +} + +func TestErrors_NewCode_Bad(t *core.T) { + err := mustError(t, NewCode("", "dispatch failed")) + got := ErrCode(err) + core.AssertEqual(t, "", got) +} + +func TestErrors_NewCode_Ugly(t *core.T) { + err := mustError(t, NewCode("", "")) + got := err.Error() + core.AssertEqual(t, "", got) +} + +func TestErrors_NewCodeWithRecovery_Good(t *core.T) { + retryAfter := time.Minute + err := mustError(t, NewCodeWithRecovery("agent.retry", "retry", true, &retryAfter, "retry")) + core.AssertEqual(t, "agent.retry", ErrCode(err)) + core.AssertTrue(t, IsRetryable(err)) +} + +func TestErrors_NewCodeWithRecovery_Bad(t *core.T) { + err := mustError(t, NewCodeWithRecovery("", "permanent", false, nil, "")) + got, ok := RetryAfter(err) + core.AssertFalse(t, IsRetryable(err)) + core.AssertFalse(t, ok) + core.AssertNil(t, got) +} + +func TestErrors_NewCodeWithRecovery_Ugly(t *core.T) { + err := mustError(t, NewCodeWithRecovery("", "", true, nil, "inspect")) + core.AssertTrue(t, IsRetryable(err)) + core.AssertEqual(t, "inspect", RecoveryAction(err)) +} + +func TestErrors_RetryAfter_Good(t *core.T) { + delay := 42 * time.Second + got, ok := RetryAfter(&Err{Msg: "retry", RetryAfter: &delay}) + core.AssertTrue(t, ok) + core.AssertEqual(t, delay, *got) +} + +func TestErrors_RetryAfter_Bad(t *core.T) { + got, ok := RetryAfter(mustError(t, NewError("plain"))) + core.AssertFalse(t, ok) + core.AssertNil(t, got) +} + +func TestErrors_RetryAfter_Ugly(t *core.T) { + delay := time.Nanosecond + err := mustError(t, Wrap(&Err{Msg: "inner", RetryAfter: &delay}, "outer", "failed")) + got, ok := RetryAfter(err) + core.AssertTrue(t, ok) + core.AssertEqual(t, delay, *got) +} + +func TestErrors_IsRetryable_Good(t *core.T) { + err := &Err{Msg: "retry", Retryable: true} + got := IsRetryable(err) + core.AssertTrue(t, got) +} + +func TestErrors_IsRetryable_Bad(t *core.T) { + err := &Err{Msg: "permanent"} + got := IsRetryable(err) + core.AssertFalse(t, got) +} + +func TestErrors_IsRetryable_Ugly(t *core.T) { + inner := &Err{Msg: "inner", Retryable: true} + err := core.Errorf("wrapped: %w", inner) + core.AssertTrue(t, IsRetryable(err)) +} + +func TestErrors_RecoveryAction_Good(t *core.T) { + err := &Err{Msg: "recover", NextAction: "retry later"} + got := RecoveryAction(err) + core.AssertEqual(t, "retry later", got) +} + +func TestErrors_RecoveryAction_Bad(t *core.T) { + err := &Err{Msg: "no action"} + got := RecoveryAction(err) + core.AssertEqual(t, "", got) +} + +func TestErrors_RecoveryAction_Ugly(t *core.T) { + inner := &Err{Msg: "inner", NextAction: "inspect"} + err := mustError(t, Wrap(inner, "outer", "failed")) + core.AssertEqual(t, "inspect", RecoveryAction(err)) +} + +func TestErrors_Is_Good(t *core.T) { + root := mustError(t, NewError("root")) + err := mustError(t, Wrap(root, "outer", "failed")) + core.AssertTrue(t, Is(err, root)) +} + +func TestErrors_Is_Bad(t *core.T) { + left := mustError(t, NewError("left")) + right := mustError(t, NewError("right")) + core.AssertFalse(t, Is(left, right)) +} + +func TestErrors_Is_Ugly(t *core.T) { + got := Is(nil, nil) + other := mustError(t, NewError("other")) + core.AssertTrue(t, got) + core.AssertFalse(t, Is(nil, other)) +} + +func TestErrors_As_Good(t *core.T) { + err := mustError(t, E("op", "msg", nil)) + var got *Err + core.AssertTrue(t, As(err, &got)) + core.AssertEqual(t, "op", got.Op) +} + +func TestErrors_As_Bad(t *core.T) { + err := mustError(t, NewError("plain")) + var got *Err + core.AssertTrue(t, As(err, &got)) + core.AssertEqual(t, "plain", got.Msg) +} + +func TestErrors_As_Ugly(t *core.T) { + var got *Err + matched := As(nil, &got) + core.AssertFalse(t, matched) + core.AssertNil(t, got) +} + +func TestErrors_NewError_Good(t *core.T) { + err := mustError(t, NewError("simple")) + got := err.Error() + core.AssertEqual(t, "simple", got) +} + +func TestErrors_NewError_Bad(t *core.T) { + err := mustError(t, NewError("")) + got := err.Error() + core.AssertEqual(t, "", got) +} + +func TestErrors_NewError_Ugly(t *core.T) { + err := mustError(t, NewError("line\nbreak")) + got := err.Error() + core.AssertEqual(t, "line\nbreak", got) +} + +func TestErrors_Join_Good(t *core.T) { + left := mustError(t, NewError("left")) + right := mustError(t, NewError("right")) + err := mustError(t, Join(left, right)) + core.AssertTrue(t, Is(err, left)) + core.AssertTrue(t, Is(err, right)) +} + +func TestErrors_Join_Bad(t *core.T) { + err := mustError(t, Join(nil, nil)) + core.AssertNil(t, err) + core.AssertNil(t, mustError(t, Root(err))) +} + +func TestErrors_Join_Ugly(t *core.T) { + root := mustError(t, NewError("root")) + err := mustError(t, Join(nil, root)) + core.AssertTrue(t, Is(err, root)) +} + +func TestErrors_Op_Good(t *core.T) { + err := mustError(t, Wrap(mustError(t, E("inner", "failed", nil)), "outer", "failed")) + got := Op(err) + core.AssertEqual(t, "outer", got) +} + +func TestErrors_Op_Bad(t *core.T) { + err := mustError(t, NewError("plain")) + got := Op(err) + core.AssertEqual(t, "", got) +} + +func TestErrors_Op_Ugly(t *core.T) { + got := Op(nil) + blank := Op(&Err{Msg: "missing operation"}) + core.AssertEqual(t, "", got) + core.AssertEqual(t, "", blank) +} + +func TestErrors_ErrCode_Good(t *core.T) { + err := mustError(t, Wrap(mustError(t, NewCode("agent.failed", "root")), "outer", "failed")) + got := ErrCode(err) + core.AssertEqual(t, "agent.failed", got) +} + +func TestErrors_ErrCode_Bad(t *core.T) { + err := mustError(t, E("op", "msg", nil)) + got := ErrCode(err) + core.AssertEqual(t, "", got) +} + +func TestErrors_ErrCode_Ugly(t *core.T) { + got := ErrCode(nil) + blank := ErrCode(&Err{Msg: "uncoded"}) + core.AssertEqual(t, "", got) + core.AssertEqual(t, "", blank) +} + +func TestErrors_Message_Good(t *core.T) { + err := mustError(t, E("op", "the message", mustError(t, NewError("root")))) + got := Message(err) + core.AssertEqual(t, "the message", got) +} + +func TestErrors_Message_Bad(t *core.T) { + err := core.NewError("plain message") + got := Message(err) + core.AssertEqual(t, "plain message", got) +} + +func TestErrors_Message_Ugly(t *core.T) { + got := Message(nil) + blank := Message(&Err{}) + core.AssertEqual(t, "", got) + core.AssertEqual(t, "", blank) +} + +func TestErrors_Root_Good(t *core.T) { + root := mustError(t, NewError("root")) + err := mustError(t, Wrap(mustError(t, Wrap(root, "inner", "failed")), "outer", "failed")) + got := mustError(t, Root(err)) + core.AssertEqual(t, root, got) +} + +func TestErrors_Root_Bad(t *core.T) { + err := mustError(t, NewError("plain")) + got := mustError(t, Root(err)) + core.AssertEqual(t, err, got) +} + +func TestErrors_Root_Ugly(t *core.T) { + left := mustError(t, NewError("left")) + err := mustError(t, Join(left, mustError(t, NewError("right")))) + got := mustError(t, Root(err)) + core.AssertEqual(t, left, got) +} + +func TestErrors_AllOps_Good(t *core.T) { + err := mustError(t, Wrap(mustError(t, E("inner", "failed", nil)), "outer", "failed")) + var ops []string + for op := range AllOps(err) { + ops = append(ops, op) + } + core.AssertEqual(t, []string{"outer", "inner"}, ops) +} + +func TestErrors_AllOps_Bad(t *core.T) { + var ops []string + for op := range AllOps(mustError(t, NewError("plain"))) { + ops = append(ops, op) + } + core.AssertEqual(t, 0, len(ops)) +} + +func TestErrors_AllOps_Ugly(t *core.T) { + err := mustError(t, Join(mustError(t, E("left", "failed", nil)), mustError(t, E("right", "failed", nil)))) + var ops []string + for op := range AllOps(err) { + ops = append(ops, op) + } + core.AssertEqual(t, []string{"left", "right"}, ops) +} + +func TestErrors_StackTrace_Good(t *core.T) { + err := mustError(t, Wrap(mustError(t, E("inner", "failed", nil)), "outer", "failed")) + got := StackTrace(err) + core.AssertEqual(t, []string{"outer", "inner"}, got) +} + +func TestErrors_StackTrace_Bad(t *core.T) { + got := StackTrace(mustError(t, NewError("plain"))) + core.AssertEqual(t, 0, len(got)) + core.AssertEqual(t, "", FormatStackTrace(mustError(t, NewError("plain")))) +} + +func TestErrors_StackTrace_Ugly(t *core.T) { + got := StackTrace(nil) + core.AssertEqual(t, 0, len(got)) + core.AssertEqual(t, "", FormatStackTrace(nil)) +} + +func TestErrors_FormatStackTrace_Good(t *core.T) { + err := mustError(t, Wrap(mustError(t, E("inner", "failed", nil)), "outer", "failed")) + got := FormatStackTrace(err) + core.AssertEqual(t, "outer -> inner", got) +} + +func TestErrors_FormatStackTrace_Bad(t *core.T) { + got := FormatStackTrace(mustError(t, NewError("plain"))) + core.AssertEqual(t, "", got) + core.AssertEqual(t, 0, len(StackTrace(mustError(t, NewError("plain"))))) +} + +func TestErrors_FormatStackTrace_Ugly(t *core.T) { + err := mustError(t, Join(mustError(t, E("left", "failed", nil)), mustError(t, E("right", "failed", nil)))) + got := FormatStackTrace(err) + core.AssertEqual(t, "left -> right", got) +} + +func TestErrors_LogError_Good(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + root := mustError(t, NewError("root")) + err := mustError(t, LogError(root, "agent.Dispatch", "failed")) + core.AssertTrue(t, Is(err, root)) + core.AssertContains(t, buf.String(), "[ERR] failed") +} + +func TestErrors_LogError_Bad(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + err := mustError(t, LogError(nil, "agent.Dispatch", "failed")) + core.AssertNil(t, err) + core.AssertEqual(t, 0, buf.Len()) +} + +func TestErrors_LogError_Ugly(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + err := mustError(t, LogError(mustError(t, E("inner", "failed", mustError(t, NewError("root")))), "outer", "failed")) + core.AssertEqual(t, "outer", Op(err)) + core.AssertContains(t, buf.String(), `stack="inner"`) +} + +func TestErrors_LogWarn_Good(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + root := mustError(t, NewError("root")) + err := mustError(t, LogWarn(root, "cache.Get", "miss")) + core.AssertTrue(t, Is(err, root)) + core.AssertContains(t, buf.String(), "[WRN] miss") +} + +func TestErrors_LogWarn_Bad(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + err := mustError(t, LogWarn(nil, "cache.Get", "miss")) + core.AssertNil(t, err) + core.AssertEqual(t, 0, buf.Len()) +} + +func TestErrors_LogWarn_Ugly(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + err := mustError(t, LogWarn(mustError(t, NewCode("cache.miss", "miss")), "cache.Get", "fallback")) + core.AssertEqual(t, "cache.miss", ErrCode(err)) + core.AssertContains(t, buf.String(), "[WRN] fallback") +} + +func TestErrors_Must_Good(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Must(nil, "startup", "ready") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestErrors_Must_Bad(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + core.AssertPanics(t, func() { Must(mustError(t, NewError("boom")), "startup", "failed") }) + core.AssertContains(t, buf.String(), "[ERR] failed") +} + +func TestErrors_Must_Ugly(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + var recovered any + func() { + defer func() { recovered = recover() }() + Must(mustError(t, NewCode("startup.failed", "boom")), "startup", "failed") + }() + err, ok := recovered.(error) + core.RequireTrue(t, ok) + core.AssertEqual(t, "startup.failed", ErrCode(err)) + core.AssertContains(t, buf.String(), "[ERR] failed") +} diff --git a/go.mod b/go/go.mod similarity index 54% rename from go.mod rename to go/go.mod index 1dbb17f..d417aa0 100644 --- a/go.mod +++ b/go/go.mod @@ -1,3 +1,5 @@ module dappco.re/go/log go 1.26.0 + +require dappco.re/go v0.9.0 diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..f11464a --- /dev/null +++ b/go/go.sum @@ -0,0 +1,2 @@ +dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0= +dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ= diff --git a/log.go b/go/log.go similarity index 90% rename from log.go rename to go/log.go index 11c10b9..027100d 100644 --- a/log.go +++ b/go/log.go @@ -1,22 +1,18 @@ -// Package log provides structured logging and error handling for Core applications. +// Package golog provides structured logging and error handling for Core applications. // -// log.SetLevel(log.LevelDebug) -// log.Info("server started", "port", 8080) -// log.Error("failed to connect", "err", err) -package log +// golog.SetLevel(golog.LevelDebug) +// golog.Info("server started", "port", 8080) +// golog.Error("failed to connect", "err", err) +package golog import ( - // AX-6 circular-dependency exception: dappco.re/go/core imports go-log to - // expose core logging and error primitives, so go-log cannot import core - // wrappers such as core.Mutex, core.RWMutex, core.Sprintf, or core.Process. - // These stdlib imports are limited to structural logging behaviour. - goio "io" "log/slog" - "os" "slices" "strconv" "sync" "time" + + core "dappco.re/go" ) // Level defines logging verbosity. @@ -72,7 +68,7 @@ type Logger struct { mu sync.RWMutex writeMu sync.Mutex level Level - output goio.Writer + output core.Writer // RedactKeys is a list of keys whose values should be masked in logs. redactKeys []string @@ -120,7 +116,7 @@ type Options struct { Level Level // Output is the destination for log messages. If Rotation is provided, // Output is ignored and logs are written to the rotating file instead. - Output goio.Writer + Output core.Writer // Rotation enables log rotation to file. If provided, Filename must be set. Rotation *RotationOptions // RedactKeys is a list of keys whose values should be masked in logs. @@ -129,13 +125,13 @@ type Options struct { // RotationWriterFactory creates a rotating writer from options. // Set this to enable log rotation (provided by core/go-io integration). -var RotationWriterFactory func(RotationOptions) goio.WriteCloser +var RotationWriterFactory func(RotationOptions) core.WriteCloser // New creates a new Logger with the given options. // -// logger := log.New(log.Options{ -// Level: log.LevelInfo, -// Output: os.Stdout, +// logger := golog.New(golog.Options{ +// Level: golog.LevelInfo, +// Output: core.Stdout(), // RedactKeys: []string{"password", "token"}, // }) func New(opts Options) *Logger { @@ -146,7 +142,7 @@ func New(opts Options) *Logger { output = RotationWriterFactory(normaliseRotationOptions(*opts.Rotation)) } if output == nil { - output = os.Stderr + output = core.Stderr() } return &Logger{ @@ -186,7 +182,7 @@ func safeStyle(style func(string) string) func(string) string { // SetLevel changes the log level. // -// logger.SetLevel(log.LevelDebug) +// logger.SetLevel(golog.LevelDebug) func (l *Logger) SetLevel(level Level) { l.mu.Lock() l.level = normaliseLevel(level) @@ -204,10 +200,10 @@ func (l *Logger) Level() Level { // SetOutput changes the output writer. // -// logger.SetOutput(os.Stdout) -func (l *Logger) SetOutput(w goio.Writer) { +// logger.SetOutput(core.Stdout()) +func (l *Logger) SetOutput(w core.Writer) { if w == nil { - w = os.Stderr + w = core.Stderr() } l.mu.Lock() l.output = w @@ -229,8 +225,7 @@ func (l *Logger) shouldLog(level Level) bool { return level <= l.level } -func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) { - _ = level +func (l *Logger) log(_ Level, prefix, msg string, keyvals ...any) { l.mu.RLock() output := l.output styleTimestamp := l.StyleTimestamp @@ -323,12 +318,14 @@ func (l *Logger) log(level Level, prefix, msg string, keyvals ...any) { l.writeMu.Lock() defer l.writeMu.Unlock() - _, _ = goio.WriteString(output, timestamp+" "+prefix+" "+normaliseLogText(msg)+kvStr+"\n") + if r := core.WriteString(output, timestamp+" "+prefix+" "+normaliseLogText(msg)+kvStr+"\n"); !r.OK { + return + } } // Debug logs a debug message with optional key-value pairs. // -// logger.Debug("processing request", "method", "GET", "path", "/api/users") +// logger.Debug("processing request", "method", "GET", "route", "/api/users") func (l *Logger) Debug(msg string, keyvals ...any) { if l.shouldLog(LevelDebug) { l.mu.RLock() @@ -394,10 +391,10 @@ func (l *Logger) Security(msg string, keyvals ...any) { // // user := log.Username() func Username() string { - if u := os.Getenv("USER"); u != "" { + if u := core.Getenv("USER"); u != "" { return u } - if u := os.Getenv("USERNAME"); u != "" { + if u := core.Getenv("USERNAME"); u != "" { return u } return "unknown" diff --git a/go/log_example_test.go b/go/log_example_test.go new file mode 100644 index 0000000..8b6ea71 --- /dev/null +++ b/go/log_example_test.go @@ -0,0 +1,205 @@ +package golog_test + +import ( + core "dappco.re/go" + golog "dappco.re/go/log" +) + +func ExampleLevel_String() { + name := golog.LevelInfo.String() + if name == "" { + panic("missing level") + } +} + +func ExampleNew() { + buf := core.NewBuffer() + logger := golog.New(golog.Options{Level: golog.LevelInfo, Output: buf}) + logger.Info("ready") + if !core.Contains(buf.String(), "ready") { + panic("missing entry") + } +} + +func ExampleLogger_SetLevel() { + logger := golog.New(golog.Options{Level: golog.LevelInfo, Output: core.Discard}) + logger.SetLevel(golog.LevelDebug) + if logger.Level() != golog.LevelDebug { + panic("missing level") + } +} + +func ExampleLogger_Level() { + logger := golog.New(golog.Options{Level: golog.LevelWarn, Output: core.Discard}) + if logger.Level() != golog.LevelWarn { + panic("missing level") + } +} + +func ExampleLogger_SetOutput() { + buf := core.NewBuffer() + logger := golog.New(golog.Options{Level: golog.LevelInfo, Output: core.Discard}) + logger.SetOutput(buf) + logger.Info("redirected") + if !core.Contains(buf.String(), "redirected") { + panic("missing output") + } +} + +func ExampleLogger_SetRedactKeys() { + buf := core.NewBuffer() + logger := golog.New(golog.Options{Level: golog.LevelInfo, Output: buf}) + logger.SetRedactKeys("token") + logger.Info("entry", "token", "secret") + if !core.Contains(buf.String(), "[REDACTED]") { + panic("missing redaction") + } +} + +func ExampleLogger_Debug() { + buf := core.NewBuffer() + logger := golog.New(golog.Options{Level: golog.LevelDebug, Output: buf}) + logger.Debug("debug") + if !core.Contains(buf.String(), "[DBG]") { + panic("missing debug") + } +} + +func ExampleLogger_Info() { + buf := core.NewBuffer() + logger := golog.New(golog.Options{Level: golog.LevelInfo, Output: buf}) + logger.Info("info") + if !core.Contains(buf.String(), "[INF]") { + panic("missing info") + } +} + +func ExampleLogger_Warn() { + buf := core.NewBuffer() + logger := golog.New(golog.Options{Level: golog.LevelWarn, Output: buf}) + logger.Warn("warn") + if !core.Contains(buf.String(), "[WRN]") { + panic("missing warn") + } +} + +func ExampleLogger_Error() { + buf := core.NewBuffer() + logger := golog.New(golog.Options{Level: golog.LevelError, Output: buf}) + logger.Error("failed", "err", core.NewError("boom")) + if !core.Contains(buf.String(), "[ERR]") { + panic("missing failure") + } +} + +func ExampleLogger_Security() { + buf := core.NewBuffer() + logger := golog.New(golog.Options{Level: golog.LevelError, Output: buf}) + logger.Security("entry") + if !core.Contains(buf.String(), "[SEC]") { + panic("missing security") + } +} + +func ExampleUsername() { + name := golog.Username() + if name == "" { + panic("missing user") + } +} + +func ExampleDefault() { + logger := golog.Default() + if logger == nil { + panic("missing default") + } +} + +func ExampleSetDefault() { + original := golog.Default() + buf := core.NewBuffer() + golog.SetDefault(golog.New(golog.Options{Level: golog.LevelInfo, Output: buf})) + defer golog.SetDefault(original) + golog.Info("default") + if !core.Contains(buf.String(), "default") { + panic("missing default") + } +} + +func ExampleSetLevel() { + original := golog.Default() + logger := golog.New(golog.Options{Level: golog.LevelInfo, Output: core.Discard}) + golog.SetDefault(logger) + defer golog.SetDefault(original) + golog.SetLevel(golog.LevelDebug) + if logger.Level() != golog.LevelDebug { + panic("missing level") + } +} + +func ExampleSetRedactKeys() { + original := golog.Default() + buf := core.NewBuffer() + golog.SetDefault(golog.New(golog.Options{Level: golog.LevelInfo, Output: buf})) + defer golog.SetDefault(original) + golog.SetRedactKeys("token") + golog.Info("entry", "token", "secret") + if !core.Contains(buf.String(), "[REDACTED]") { + panic("missing redaction") + } +} + +func ExampleDebug() { + original := golog.Default() + buf := core.NewBuffer() + golog.SetDefault(golog.New(golog.Options{Level: golog.LevelDebug, Output: buf})) + defer golog.SetDefault(original) + golog.Debug("debug") + if !core.Contains(buf.String(), "[DBG]") { + panic("missing debug") + } +} + +func ExampleInfo() { + original := golog.Default() + buf := core.NewBuffer() + golog.SetDefault(golog.New(golog.Options{Level: golog.LevelInfo, Output: buf})) + defer golog.SetDefault(original) + golog.Info("info") + if !core.Contains(buf.String(), "[INF]") { + panic("missing info") + } +} + +func ExampleWarn() { + original := golog.Default() + buf := core.NewBuffer() + golog.SetDefault(golog.New(golog.Options{Level: golog.LevelDebug, Output: buf})) + defer golog.SetDefault(original) + golog.Warn("warn") + if !core.Contains(buf.String(), "[WRN]") { + panic("missing warn") + } +} + +func ExampleError() { + original := golog.Default() + buf := core.NewBuffer() + golog.SetDefault(golog.New(golog.Options{Level: golog.LevelDebug, Output: buf})) + defer golog.SetDefault(original) + golog.Error("failed", "err", core.NewError("boom")) + if !core.Contains(buf.String(), "[ERR]") { + panic("missing failure") + } +} + +func ExampleSecurity() { + original := golog.Default() + buf := core.NewBuffer() + golog.SetDefault(golog.New(golog.Options{Level: golog.LevelDebug, Output: buf})) + defer golog.SetDefault(original) + golog.Security("entry") + if !core.Contains(buf.String(), "[SEC]") { + panic("missing security") + } +} diff --git a/go/log_test.go b/go/log_test.go new file mode 100644 index 0000000..0a53877 --- /dev/null +++ b/go/log_test.go @@ -0,0 +1,514 @@ +package golog + +import core "dappco.re/go" + +type testWriteCloser struct { + core.Writer + closed bool +} + +func (w *testWriteCloser) Close() error { + w.closed = true + return nil +} + +func TestLog_Level_String_Good(t *core.T) { + got := LevelInfo.String() + core.AssertEqual(t, "info", got) + core.AssertNotEqual(t, "debug", got) +} + +func TestLog_Level_String_Bad(t *core.T) { + got := Level(99).String() + core.AssertEqual(t, "unknown", got) + core.AssertNotEqual(t, "info", got) +} + +func TestLog_Level_String_Ugly(t *core.T) { + got := Level(-1).String() + core.AssertEqual(t, "unknown", got) + core.AssertNotEqual(t, "quiet", got) +} + +func TestLog_New_Good(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelDebug, Output: buf}) + logger.Debug("ready") + core.AssertContains(t, buf.String(), "[DBG] ready") +} + +func TestLog_New_Bad(t *core.T) { + logger := New(Options{Level: Level(99), Output: core.Discard}) + got := logger.Level() + core.AssertEqual(t, LevelInfo, got) +} + +func TestLog_New_Ugly(t *core.T) { + keys := []string{"secret"} + logger := New(Options{Level: LevelInfo, Output: core.Discard, RedactKeys: keys}) + keys[0] = "mutated" + core.AssertEqual(t, "secret", logger.redactKeys[0]) +} + +func TestLog_Logger_SetLevel_Good(t *core.T) { + logger := New(Options{Level: LevelInfo, Output: core.Discard}) + logger.SetLevel(LevelDebug) + core.AssertEqual(t, LevelDebug, logger.Level()) +} + +func TestLog_Logger_SetLevel_Bad(t *core.T) { + logger := New(Options{Level: LevelDebug, Output: core.Discard}) + logger.SetLevel(Level(99)) + core.AssertEqual(t, LevelInfo, logger.Level()) +} + +func TestLog_Logger_SetLevel_Ugly(t *core.T) { + logger := New(Options{Level: LevelDebug, Output: core.Discard}) + logger.SetLevel(LevelQuiet) + core.AssertEqual(t, LevelQuiet, logger.Level()) +} + +func TestLog_Logger_Level_Good(t *core.T) { + logger := New(Options{Level: LevelWarn, Output: core.Discard}) + got := logger.Level() + core.AssertEqual(t, LevelWarn, got) +} + +func TestLog_Logger_Level_Bad(t *core.T) { + logger := New(Options{Level: Level(-5), Output: core.Discard}) + got := logger.Level() + core.AssertEqual(t, LevelInfo, got) +} + +func TestLog_Logger_Level_Ugly(t *core.T) { + logger := New(Options{Level: LevelQuiet, Output: core.Discard}) + got := logger.Level() + core.AssertEqual(t, LevelQuiet, got) +} + +func TestLog_Logger_SetOutput_Good(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelInfo, Output: core.Discard}) + logger.SetOutput(buf) + logger.Info("switched") + core.AssertContains(t, buf.String(), "switched") +} + +func TestLog_Logger_SetOutput_Bad(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelInfo, Output: buf}) + logger.SetOutput(nil) + core.AssertNotNil(t, logger.output) + core.AssertNotEqual(t, buf, logger.output) +} + +func TestLog_Logger_SetOutput_Ugly(t *core.T) { + first := core.NewBuffer() + second := core.NewBuffer() + logger := New(Options{Level: LevelInfo, Output: first}) + logger.SetOutput(second) + logger.Info("after") + core.AssertEqual(t, 0, first.Len()) + core.AssertContains(t, second.String(), "after") +} + +func TestLog_Logger_SetRedactKeys_Good(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelInfo, Output: buf}) + logger.SetRedactKeys("secret") + logger.Info("entry", "secret", "token") + core.AssertContains(t, buf.String(), `secret="[REDACTED]"`) +} + +func TestLog_Logger_SetRedactKeys_Bad(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelInfo, Output: buf, RedactKeys: []string{"secret"}}) + logger.SetRedactKeys() + logger.Info("entry", "secret", "visible") + core.AssertContains(t, buf.String(), `secret="visible"`) +} + +func TestLog_Logger_SetRedactKeys_Ugly(t *core.T) { + keys := []string{"secret"} + logger := New(Options{Level: LevelInfo, Output: core.Discard}) + logger.SetRedactKeys(keys...) + keys[0] = "mutated" + core.AssertEqual(t, "secret", logger.redactKeys[0]) +} + +func TestLog_Logger_Debug_Good(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelDebug, Output: buf}) + logger.Debug("debug", "agent", "codex") + core.AssertContains(t, buf.String(), `[DBG] debug agent="codex"`) +} + +func TestLog_Logger_Debug_Bad(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelInfo, Output: buf}) + logger.Debug("hidden") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestLog_Logger_Debug_Ugly(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelDebug, Output: buf}) + logger.StyleDebug = nil + logger.Debug("nil-style") + core.AssertContains(t, buf.String(), "[DBG] nil-style") +} + +func TestLog_Logger_Info_Good(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelInfo, Output: buf}) + logger.Info("info", "ok", true) + core.AssertContains(t, buf.String(), "[INF] info ok=true") +} + +func TestLog_Logger_Info_Bad(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelWarn, Output: buf}) + logger.Info("hidden") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestLog_Logger_Info_Ugly(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelInfo, Output: buf}) + logger.Info("line\nbreak", "key\tname", "value\r") + core.AssertContains(t, buf.String(), `line\nbreak key\tname="value\r"`) +} + +func TestLog_Logger_Warn_Good(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelWarn, Output: buf}) + logger.Warn("warn", "attempt", 2) + core.AssertContains(t, buf.String(), "[WRN] warn attempt=2") +} + +func TestLog_Logger_Warn_Bad(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelError, Output: buf}) + logger.Warn("hidden") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestLog_Logger_Warn_Ugly(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelWarn, Output: buf}) + logger.StyleWarn = func(s string) string { return "WARN:" + s } + logger.Warn("styled") + core.AssertContains(t, buf.String(), "WARN:[WRN] styled") +} + +func TestLog_Logger_Error_Good(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelError, Output: buf}) + logger.Error("failed", "err", mustError(t, NewError("boom"))) + core.AssertContains(t, buf.String(), "[ERR] failed err=boom") +} + +func TestLog_Logger_Error_Bad(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelQuiet, Output: buf}) + logger.Error("hidden") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestLog_Logger_Error_Ugly(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelError, Output: buf}) + err := mustError(t, Wrap(mustError(t, E("inner.Op", "inner failed", mustError(t, NewError("root")))), "outer.Op", "outer failed")) + logger.Error("failed", "err", err) + core.AssertContains(t, buf.String(), `stack="outer.Op -> inner.Op"`) +} + +func TestLog_Logger_Security_Good(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelError, Output: buf}) + logger.Security("entry", "user", "alice") + core.AssertContains(t, buf.String(), `[SEC] entry user="alice"`) +} + +func TestLog_Logger_Security_Bad(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelQuiet, Output: buf}) + logger.Security("hidden") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestLog_Logger_Security_Ugly(t *core.T) { + buf := core.NewBuffer() + logger := New(Options{Level: LevelError, Output: buf}) + logger.StyleSecurity = func(s string) string { return "SEC:" + s } + logger.Security("styled") + core.AssertContains(t, buf.String(), "SEC:[SEC] styled") +} + +func TestLog_Username_Good(t *core.T) { + t.Setenv("USER", "codex") + got := Username() + core.AssertEqual(t, "codex", got) +} + +func TestLog_Username_Bad(t *core.T) { + t.Setenv("USER", "") + t.Setenv("USERNAME", "fallback") + core.AssertEqual(t, "fallback", Username()) +} + +func TestLog_Username_Ugly(t *core.T) { + t.Setenv("USER", "") + t.Setenv("USERNAME", "") + core.AssertEqual(t, "unknown", Username()) +} + +func TestLog_Default_Good(t *core.T) { + got := Default() + core.AssertNotNil(t, got) + core.AssertEqual(t, LevelInfo, got.Level()) +} + +func TestLog_Default_Bad(t *core.T) { + original := Default() + SetDefault(nil) + core.AssertEqual(t, original, Default()) +} + +func TestLog_Default_Ugly(t *core.T) { + original := Default() + custom := New(Options{Level: LevelDebug, Output: core.Discard}) + SetDefault(custom) + t.Cleanup(func() { SetDefault(original) }) + core.AssertEqual(t, custom, Default()) +} + +func TestLog_SetDefault_Good(t *core.T) { + original := Default() + custom := New(Options{Level: LevelWarn, Output: core.Discard}) + SetDefault(custom) + t.Cleanup(func() { SetDefault(original) }) + core.AssertEqual(t, custom, Default()) +} + +func TestLog_SetDefault_Bad(t *core.T) { + original := Default() + SetDefault(nil) + core.AssertEqual(t, original, Default()) +} + +func TestLog_SetDefault_Ugly(t *core.T) { + original := Default() + first := New(Options{Level: LevelInfo, Output: core.Discard}) + second := New(Options{Level: LevelDebug, Output: core.Discard}) + SetDefault(first) + SetDefault(second) + t.Cleanup(func() { SetDefault(original) }) + core.AssertEqual(t, second, Default()) +} + +func TestLog_SetLevel_Good(t *core.T) { + original := Default() + logger := New(Options{Level: LevelInfo, Output: core.Discard}) + SetDefault(logger) + t.Cleanup(func() { SetDefault(original) }) + SetLevel(LevelDebug) + core.AssertEqual(t, LevelDebug, logger.Level()) +} + +func TestLog_SetLevel_Bad(t *core.T) { + original := Default() + logger := New(Options{Level: LevelDebug, Output: core.Discard}) + SetDefault(logger) + t.Cleanup(func() { SetDefault(original) }) + SetLevel(Level(99)) + core.AssertEqual(t, LevelInfo, logger.Level()) +} + +func TestLog_SetLevel_Ugly(t *core.T) { + original := Default() + logger := New(Options{Level: LevelDebug, Output: core.Discard}) + SetDefault(logger) + t.Cleanup(func() { SetDefault(original) }) + SetLevel(LevelQuiet) + core.AssertEqual(t, LevelQuiet, logger.Level()) +} + +func TestLog_SetRedactKeys_Good(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelInfo, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + SetRedactKeys("token") + Info("entry", "token", "abc") + core.AssertContains(t, buf.String(), `token="[REDACTED]"`) +} + +func TestLog_SetRedactKeys_Bad(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelInfo, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + SetRedactKeys("token") + SetRedactKeys() + Info("entry", "token", "abc") + core.AssertContains(t, buf.String(), `token="abc"`) +} + +func TestLog_SetRedactKeys_Ugly(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelInfo, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + SetRedactKeys("") + Info("empty-key", "", "secret") + core.AssertContains(t, buf.String(), `="[REDACTED]"`) +} + +func TestLog_Debug_Good(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Debug("debug", "agent", "codex") + core.AssertContains(t, buf.String(), `[DBG] debug agent="codex"`) +} + +func TestLog_Debug_Bad(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelInfo, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Debug("hidden") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestLog_Debug_Ugly(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Debug("control\nchars", "key", "value\t") + core.AssertContains(t, buf.String(), `control\nchars key="value\t"`) +} + +func TestLog_Info_Good(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelInfo, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Info("info", "ok", true) + core.AssertContains(t, buf.String(), "[INF] info ok=true") +} + +func TestLog_Info_Bad(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelWarn, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Info("hidden") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestLog_Info_Ugly(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelInfo, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Info("odd", "lonely") + core.AssertContains(t, buf.String(), "lonely=") +} + +func TestLog_Warn_Good(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Warn("warn", "attempt", 2) + core.AssertContains(t, buf.String(), "[WRN] warn attempt=2") +} + +func TestLog_Warn_Bad(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelError, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Warn("hidden") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestLog_Warn_Ugly(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Warn("newline", "value", "a\nb") + core.AssertContains(t, buf.String(), `value="a\nb"`) +} + +func TestLog_Error_Good(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Error("failed", "err", mustError(t, NewError("boom"))) + core.AssertContains(t, buf.String(), "[ERR] failed err=boom") +} + +func TestLog_Error_Bad(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelQuiet, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Error("hidden") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestLog_Error_Ugly(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Error("failed", "err", mustError(t, E("op.Name", "failed", mustError(t, NewError("root"))))) + core.AssertContains(t, buf.String(), `op="op.Name"`) +} + +func TestLog_Security_Good(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Security("entry", "user", "alice") + core.AssertContains(t, buf.String(), `[SEC] entry user="alice"`) +} + +func TestLog_Security_Bad(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelQuiet, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Security("hidden") + core.AssertEqual(t, 0, buf.Len()) +} + +func TestLog_Security_Ugly(t *core.T) { + original := Default() + buf := core.NewBuffer() + SetDefault(New(Options{Level: LevelDebug, Output: buf})) + t.Cleanup(func() { SetDefault(original) }) + Security("line\nbreak", "ip", "127.0.0.1") + core.AssertContains(t, buf.String(), `[SEC] line\nbreak ip="127.0.0.1"`) +} + +func TestLog_New_RotationWriter_Good(t *core.T) { + buf := core.NewBuffer() + original := RotationWriterFactory + RotationWriterFactory = func(opts RotationOptions) core.WriteCloser { + core.AssertEqual(t, "app.out", opts.Filename) + return &testWriteCloser{Writer: buf} + } + t.Cleanup(func() { RotationWriterFactory = original }) + logger := New(Options{Level: LevelInfo, Rotation: &RotationOptions{Filename: "app.out"}}) + logger.Info("rotated") + core.AssertContains(t, buf.String(), "rotated") +} diff --git a/tests/cli/log/Taskfile.yaml b/go/tests/cli/log/Taskfile.yaml similarity index 100% rename from tests/cli/log/Taskfile.yaml rename to go/tests/cli/log/Taskfile.yaml diff --git a/go/tests/cli/log/main.go b/go/tests/cli/log/main.go new file mode 100644 index 0000000..c6ac0d7 --- /dev/null +++ b/go/tests/cli/log/main.go @@ -0,0 +1,265 @@ +// AX-10 CLI driver for go-log. It exercises the public logging and structured +// error helpers without depending on the repository's unit test package. +// +// task -d go/tests/cli/log test +// go run ./go/tests/cli/log +package main + +import ( + "time" + + core "dappco.re/go" + corelog "dappco.re/go/log" +) + +func main() { + if r := run(); !r.OK { + core.Print(core.Stderr(), "%s", r.Error()) + core.Exit(1) + } +} + +func run() core.Result { + if r := verifyLoggerOutput(); !r.OK { + return core.Fail(core.Errorf("logger output: %s", r.Error())) + } + if r := verifyStructuredErrors(); !r.OK { + return core.Fail(core.Errorf("structured values: %s", r.Error())) + } + if r := verifyDefaultLoggerAndRotationHooks(); !r.OK { + return core.Fail(core.Errorf("default logger and rotation hooks: %s", r.Error())) + } + if username := corelog.Username(); username == "" { + return core.Fail(core.NewError("username should not be empty")) + } + return core.Ok(nil) +} + +func verifyLoggerOutput() core.Result { + buf := core.NewBuffer() + logger := corelog.New(corelog.Options{ + Level: corelog.LevelDebug, + Output: buf, + RedactKeys: []string{"secret"}, + }) + + network := core.NewError("network") + retryAfter := 3 * time.Second + inner := requireError(corelog.EWithRecovery("driver.inner", "temporary failure", network, true, &retryAfter, "retry later"), "inner") + if !inner.OK { + return inner + } + wrapped := requireError(corelog.WrapCode(inner.Value.(error), "AX10_TEMPORARY", "driver.outer", "outer failure"), "outer") + if !wrapped.OK { + return wrapped + } + + logger.Debug("debug event", "component", "ax-10") + logger.Info("login\nattempt", "user", "alice", "secret", "token\nvalue") + logger.Warn("slow route", "attempt", 2) + logger.Error("request failed", "err", wrapped.Value.(error)) + logger.Security("suspicious entry", "ip", "127.0.0.1") + + output := buf.String() + if r := containsAll(output, []string{ + "[DBG] debug event", + "[INF] login\\nattempt", + "user=\"alice\"", + "secret=\"[REDACTED]\"", + "[WRN] slow route attempt=2", + "[ERR] request failed", + "err=driver.outer: outer failure [AX10_TEMPORARY]: driver.inner: temporary failure: network", + "op=\"driver.outer\"", + "stack=\"driver.outer -> driver.inner\"", + "retryable=true", + "retry_after_seconds=3", + "next_action=\"retry later\"", + "[SEC] suspicious entry ip=\"127.0.0.1\"", + }); !r.OK { + return r + } + + if core.Contains(output, "token\nvalue") { + return core.Fail(core.NewError("output contains an unescaped newline in a value")) + } + if got := lineCount(output); got != 5 { + return core.Fail(core.Errorf("expected 5 output lines, got %d", got)) + } + + quiet := core.NewBuffer() + corelog.New(corelog.Options{Level: corelog.LevelQuiet, Output: quiet}).Security("hidden") + if quiet.Len() != 0 { + return core.Fail(core.NewError("quiet logger emitted security output")) + } + + return core.Ok(nil) +} + +func verifyStructuredErrors() core.Result { + root := core.NewError("root cause") + inner := requireError(corelog.WrapCode(root, "AX10_FAILURE", "driver.inner", "inner failed"), "inner") + if !inner.OK { + return inner + } + outer := requireError(corelog.Wrap(inner.Value.(error), "driver.outer", "outer failed"), "outer") + if !outer.OK { + return outer + } + outerErr := outer.Value.(error) + + if !corelog.Is(outerErr, root) { + return core.Fail(core.NewError("wrapped value should match root cause")) + } + rootResult := requireError(corelog.Root(outerErr), "root") + if !rootResult.OK { + return rootResult + } + if rootResult.Value.(error) != root { + return core.Fail(core.Errorf("root = %v, want %v", rootResult.Value, root)) + } + if got := corelog.ErrCode(outerErr); got != "AX10_FAILURE" { + return core.Fail(core.Errorf("code = %q, want AX10_FAILURE", got)) + } + if got := corelog.Op(outerErr); got != "driver.outer" { + return core.Fail(core.Errorf("op = %q, want driver.outer", got)) + } + if got := corelog.Message(outerErr); got != "outer failed" { + return core.Fail(core.Errorf("message = %q, want outer failed", got)) + } + if got := corelog.FormatStackTrace(outerErr); got != "driver.outer -> driver.inner" { + return core.Fail(core.Errorf("stack trace = %q", got)) + } + + ops := make([]string, 0, 2) + for op := range corelog.AllOps(outerErr) { + ops = append(ops, op) + } + if core.Join(",", ops...) != "driver.outer,driver.inner" { + return core.Fail(core.Errorf("ops = %v", ops)) + } + + retryAfter := 5 * time.Second + retryable := requireError(corelog.WrapWithRecovery(root, "driver.retry", "retryable failure", true, &retryAfter, "retry with backoff"), "retryable") + if !retryable.OK { + return retryable + } + retryErr := retryable.Value.(error) + if !corelog.IsRetryable(retryErr) { + return core.Fail(core.NewError("retryable value should report retryable")) + } + gotRetryAfter, ok := corelog.RetryAfter(retryErr) + if !ok || gotRetryAfter == nil || *gotRetryAfter != retryAfter { + return core.Fail(core.Errorf("retry after = %v, ok=%v", gotRetryAfter, ok)) + } + if got := corelog.RecoveryAction(retryErr); got != "retry with backoff" { + return core.Fail(core.Errorf("recovery action = %q", got)) + } + + joined := requireError(corelog.Join(outerErr, requireError(corelog.NewCode("AX10_JOINED", "joined failure"), "joined").Value.(error)), "joined") + if !joined.OK { + return joined + } + var logErr *corelog.Err + if !corelog.As(joined.Value.(error), &logErr) { + return core.Fail(core.NewError("joined value should expose a structured value")) + } + plain := requireError(corelog.NewError("plain"), "plain") + if !plain.OK { + return plain + } + if got := plain.Value.(error).Error(); got != "plain" { + return core.Fail(core.Errorf("new value = %q, want plain", got)) + } + + return core.Ok(nil) +} + +func verifyDefaultLoggerAndRotationHooks() core.Result { + defaultBuf := core.NewBuffer() + originalDefault := corelog.Default() + corelog.SetDefault(corelog.New(corelog.Options{Level: corelog.LevelInfo, Output: defaultBuf})) + defer corelog.SetDefault(originalDefault) + + corelog.Info("default info", "ok", true) + if output := defaultBuf.String(); !core.Contains(output, "[INF] default info ok=true") { + return core.Fail(core.Errorf("default logger output = %q", output)) + } + + fsys := (&core.Fs{}).NewUnrestricted() + dir := fsys.TempDir("go-log-cli-") + if dir == "" { + return core.Fail(core.NewError("temporary directory unavailable")) + } + defer func() { + if r := fsys.DeleteAll(dir); !r.OK { + core.Print(core.Stderr(), "cleanup failed: %s", r.Error()) + } + }() + + rotatedPath := core.PathJoin(dir, "ax10.out") + originalFactory := corelog.RotationWriterFactory + corelog.RotationWriterFactory = func(opts corelog.RotationOptions) core.WriteCloser { + if opts.Filename != "ax10.out" { + return nil + } + r := core.OpenFile(rotatedPath, core.O_CREATE|core.O_WRONLY|core.O_TRUNC, 0o600) + if !r.OK { + return nil + } + return r.Value.(core.WriteCloser) + } + defer func() { + corelog.RotationWriterFactory = originalFactory + }() + + logger := corelog.New(corelog.Options{ + Level: corelog.LevelInfo, + Rotation: &corelog.RotationOptions{Filename: "ax10.out"}, + }) + logger.Info("rotated output") + read := core.ReadFile(rotatedPath) + if !read.OK { + return read + } + if output := string(read.Value.([]byte)); !core.Contains(output, "[INF] rotated output") { + return core.Fail(core.Errorf("rotation writer output = %q", output)) + } + + return core.Ok(nil) +} + +func requireError(r core.Result, label string) core.Result { + if !r.OK { + return r + } + if r.Value == nil { + return core.Fail(core.Errorf("%s value is nil", label)) + } + if _, ok := r.Value.(error); !ok { + return core.Fail(core.Errorf("%s value is %T", label, r.Value)) + } + return r +} + +func containsAll(output string, wants []string) core.Result { + for _, want := range wants { + if !core.Contains(output, want) { + return core.Fail(core.Errorf("output missing %q in %q", want, output)) + } + } + return core.Ok(nil) +} + +func lineCount(output string) int { + trimmed := core.Trim(output) + if trimmed == "" { + return 0 + } + count := 1 + for _, r := range trimmed { + if r == '\n' { + count++ + } + } + return count +} diff --git a/go/tests/cli/log/main_test.go b/go/tests/cli/log/main_test.go new file mode 100644 index 0000000..324bc4f --- /dev/null +++ b/go/tests/cli/log/main_test.go @@ -0,0 +1,22 @@ +package main + +import core "dappco.re/go" + +func TestMain_run_Good(t *core.T) { + r := run() + core.AssertTrue(t, r.OK) + core.AssertNil(t, r.Value) +} + +func TestMain_containsAll_Bad(t *core.T) { + r := containsAll("present", []string{"missing"}) + core.AssertFalse(t, r.OK) + core.AssertContains(t, r.Error(), "output missing") +} + +func TestMain_lineCount_Ugly(t *core.T) { + empty := lineCount("") + two := lineCount("first\nsecond\n") + core.AssertEqual(t, 0, empty) + core.AssertEqual(t, 2, two) +} diff --git a/log_test.go b/log_test.go deleted file mode 100644 index 8ecc9f6..0000000 --- a/log_test.go +++ /dev/null @@ -1,538 +0,0 @@ -package log - -import ( - // Note: test-only stdlib (no core equivalent for io.Writer capture). - "bytes" - goio "io" - // Note: test-only stdlib (no core equivalent for rendered output assertions). - "strings" - "testing" - "time" -) - -// nopWriteCloser wraps a writer with a no-op Close for testing rotation. -type nopWriteCloser struct{ goio.Writer } - -func (nopWriteCloser) Close() error { return nil } - -func TestLogger_Levels_Good(t *testing.T) { - tests := []struct { - name string - level Level - logFunc func(*Logger, string, ...any) - expected bool - }{ - {"debug at debug", LevelDebug, (*Logger).Debug, true}, - {"info at debug", LevelDebug, (*Logger).Info, true}, - {"warn at debug", LevelDebug, (*Logger).Warn, true}, - {"error at debug", LevelDebug, (*Logger).Error, true}, - - {"debug at info", LevelInfo, (*Logger).Debug, false}, - {"info at info", LevelInfo, (*Logger).Info, true}, - {"warn at info", LevelInfo, (*Logger).Warn, true}, - {"error at info", LevelInfo, (*Logger).Error, true}, - - {"debug at warn", LevelWarn, (*Logger).Debug, false}, - {"info at warn", LevelWarn, (*Logger).Info, false}, - {"warn at warn", LevelWarn, (*Logger).Warn, true}, - {"error at warn", LevelWarn, (*Logger).Error, true}, - - {"debug at error", LevelError, (*Logger).Debug, false}, - {"info at error", LevelError, (*Logger).Info, false}, - {"warn at error", LevelError, (*Logger).Warn, false}, - {"error at error", LevelError, (*Logger).Error, true}, - - {"debug at quiet", LevelQuiet, (*Logger).Debug, false}, - {"info at quiet", LevelQuiet, (*Logger).Info, false}, - {"warn at quiet", LevelQuiet, (*Logger).Warn, false}, - {"error at quiet", LevelQuiet, (*Logger).Error, false}, - - {"security at info", LevelInfo, (*Logger).Security, true}, - {"security at error", LevelError, (*Logger).Security, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: tt.level, Output: &buf}) - tt.logFunc(l, "test message") - - hasOutput := buf.Len() > 0 - if hasOutput != tt.expected { - t.Errorf("expected output=%v, got output=%v", tt.expected, hasOutput) - } - }) - } -} - -func TestLogger_KeyValues_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelDebug, Output: &buf}) - - l.Info("test message", "key1", "value1", "key2", 42) - - output := buf.String() - if !strings.Contains(output, "test message") { - t.Error("expected message in output") - } - if !strings.Contains(output, "key1=\"value1\"") { - t.Errorf("expected key1=\"value1\" in output, got %q", output) - } - if !strings.Contains(output, "key2=42") { - t.Error("expected key2=42 in output") - } -} - -func TestLogger_ErrorContext_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Output: &buf, Level: LevelInfo}) - - err := E("test.Op", "failed", NewError("root cause")) - err = Wrap(err, "outer.Op", "outer failed") - - l.Error("something failed", "err", err) - - got := buf.String() - if !strings.Contains(got, "op=\"outer.Op\"") { - t.Errorf("expected output to contain op=\"outer.Op\", got %q", got) - } - if !strings.Contains(got, "stack=\"outer.Op -> test.Op\"") { - t.Errorf("expected output to contain stack=\"outer.Op -> test.Op\", got %q", got) - } -} - -func TestLogger_ErrorContextIncludesRecovery_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Output: &buf, Level: LevelInfo}) - retryAfter := 45 * time.Second - - err := EWithRecovery("retryable.Op", "temporary failure", NewError("temporary failure"), true, &retryAfter, "retry with backoff") - l.Error("request failed", "err", err) - - output := buf.String() - if !strings.Contains(output, "retryable=true") { - t.Errorf("expected output to contain retryable=true, got %q", output) - } - if !strings.Contains(output, "retry_after_seconds=45") { - t.Errorf("expected output to contain retry_after_seconds=45, got %q", output) - } - if !strings.Contains(output, "next_action=\"retry with backoff\"") { - t.Errorf("expected output to contain next_action=\"retry with backoff\", got %q", output) - } -} - -func TestLogger_ErrorContextIncludesNestedRecovery_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Output: &buf, Level: LevelInfo}) - retryAfter := 30 * time.Second - - inner := &Err{ - Msg: "inner failure", - Retryable: true, - RetryAfter: &retryAfter, - NextAction: "retry later", - } - outer := &Err{Msg: "outer failure", Err: inner} - - l.Error("request failed", "err", outer) - - output := buf.String() - if !strings.Contains(output, "retryable=true") { - t.Errorf("expected output to contain retryable=true, got %q", output) - } - if !strings.Contains(output, "retry_after_seconds=30") { - t.Errorf("expected output to contain retry_after_seconds=30, got %q", output) - } - if !strings.Contains(output, "next_action=\"retry later\"") { - t.Errorf("expected output to contain next_action=\"retry later\", got %q", output) - } -} - -func TestLogger_Redaction_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{ - Level: LevelInfo, - Output: &buf, - RedactKeys: []string{"password", "token"}, - }) - - l.Info("login", "user", "admin", "password", "secret123", "token", "abc-123") - - output := buf.String() - if !strings.Contains(output, "user=\"admin\"") { - t.Error("expected user=\"admin\"") - } - if !strings.Contains(output, "password=\"[REDACTED]\"") { - t.Errorf("expected password=\"[REDACTED]\", got %q", output) - } - if !strings.Contains(output, "token=\"[REDACTED]\"") { - t.Errorf("expected token=\"[REDACTED]\", got %q", output) - } -} - -func TestLogger_Redaction_Bad_CaseMismatchNotRedacted(t *testing.T) { - var buf bytes.Buffer - l := New(Options{ - Level: LevelInfo, - Output: &buf, - RedactKeys: []string{"password"}, - }) - - l.Info("login", "PASSWORD", "secret123") - - output := buf.String() - if !strings.Contains(output, "PASSWORD=\"secret123\"") { - t.Errorf("expected case-mismatched key to remain visible, got %q", output) - } -} - -func TestLogger_InjectionPrevention_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelInfo, Output: &buf}) - - l.Info("message", "key", "value\n[SEC] injected message") - - output := buf.String() - if !strings.Contains(output, "key=\"value\\n[SEC] injected message\"") { - t.Errorf("expected escaped newline, got %q", output) - } - // Ensure it's still a single line (excluding trailing newline) - lines := strings.Split(strings.TrimSpace(output), "\n") - if len(lines) != 1 { - t.Errorf("expected 1 line, got %d", len(lines)) - } -} - -func TestLogger_KeySanitization_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelInfo, Output: &buf}) - - l.Info("message", "key\nwith newline", "value\nwith newline") - output := buf.String() - - if !strings.Contains(output, "key\\nwith newline") { - t.Errorf("expected sanitized key, got %q", output) - } - if !strings.Contains(output, "value\\nwith newline") { - t.Errorf("expected sanitized value, got %q", output) - } - - lines := strings.Split(strings.TrimSpace(output), "\n") - if len(lines) != 1 { - t.Errorf("expected 1 line, got %d", len(lines)) - } -} - -func TestLogger_MessageSanitization_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelInfo, Output: &buf}) - - l.Info("message\nwith\tcontrol\rchars") - output := buf.String() - - if !strings.Contains(output, "message\\nwith\\tcontrol\\rchars") { - t.Errorf("expected control characters to be escaped, got %q", output) - } - - lines := strings.Split(strings.TrimSpace(output), "\n") - if len(lines) != 1 { - t.Errorf("expected 1 line, got %d", len(lines)) - } -} - -func TestLogger_SetLevel_Good(t *testing.T) { - l := New(Options{Level: LevelInfo}) - - if l.Level() != LevelInfo { - t.Error("expected initial level to be Info") - } - - l.SetLevel(LevelDebug) - if l.Level() != LevelDebug { - t.Error("expected level to be Debug after SetLevel") - } - - l.SetLevel(99) - if l.Level() != LevelInfo { - t.Errorf("expected invalid level to default back to info, got %v", l.Level()) - } -} - -func TestLevel_String_Good(t *testing.T) { - tests := []struct { - level Level - expected string - }{ - {LevelQuiet, "quiet"}, - {LevelError, "error"}, - {LevelWarn, "warn"}, - {LevelInfo, "info"}, - {LevelDebug, "debug"}, - {Level(99), "unknown"}, - } - - for _, tt := range tests { - t.Run(tt.expected, func(t *testing.T) { - if got := tt.level.String(); got != tt.expected { - t.Errorf("expected %q, got %q", tt.expected, got) - } - }) - } -} - -func TestLogger_Security_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelError, Output: &buf}) - - l.Security("unauthorized access", "user", "admin") - - output := buf.String() - if !strings.Contains(output, "[SEC]") { - t.Error("expected [SEC] prefix in security log") - } - if !strings.Contains(output, "unauthorized access") { - t.Error("expected message in security log") - } - if !strings.Contains(output, "user=\"admin\"") { - t.Error("expected context in security log") - } -} - -func TestLogger_SetOutput_Good(t *testing.T) { - var buf1, buf2 bytes.Buffer - l := New(Options{Level: LevelInfo, Output: &buf1}) - - l.Info("first") - if buf1.Len() == 0 { - t.Error("expected output in first buffer") - } - - l.SetOutput(&buf2) - l.Info("second") - if buf2.Len() == 0 { - t.Error("expected output in second buffer after SetOutput") - } -} - -func TestLogger_SetOutput_Bad_NilUsesFallback(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelInfo, Output: &buf}) - - l.SetOutput(nil) - - if l.output == nil { - t.Error("expected nil output to install a fallback writer") - } - if l.output == &buf { - t.Error("expected nil output to replace the previous writer") - } -} - -func TestLogger_SetRedactKeys_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelInfo, Output: &buf}) - - // No redaction initially - l.Info("msg", "secret", "visible") - if !strings.Contains(buf.String(), "secret=\"visible\"") { - t.Errorf("expected visible value, got %q", buf.String()) - } - - buf.Reset() - l.SetRedactKeys("secret") - l.Info("msg", "secret", "hidden") - if !strings.Contains(buf.String(), "secret=\"[REDACTED]\"") { - t.Errorf("expected redacted value, got %q", buf.String()) - } -} - -func TestLogger_OddKeyvals_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelInfo, Output: &buf}) - - // Odd number of keyvals — last key should have no value - l.Info("msg", "lonely_key") - output := buf.String() - if !strings.Contains(output, "lonely_key=") { - t.Errorf("expected lonely_key=, got %q", output) - } -} - -func TestLogger_ExistingOpNotDuplicated_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelInfo, Output: &buf}) - - err := E("inner.Op", "failed", NewError("cause")) - // Pass op explicitly — should not duplicate - l.Error("failed", "op", "explicit.Op", "err", err) - - output := buf.String() - if strings.Count(output, "op=") != 1 { - t.Errorf("expected exactly one op= in output, got %q", output) - } - if !strings.Contains(output, "op=\"explicit.Op\"") { - t.Errorf("expected explicit op, got %q", output) - } -} - -func TestLogger_ExistingStackNotDuplicated_Good(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelInfo, Output: &buf}) - - err := E("inner.Op", "failed", NewError("cause")) - // Pass stack explicitly — should not duplicate - l.Error("failed", "stack", "custom.Stack", "err", err) - - output := buf.String() - if strings.Count(output, "stack=") != 1 { - t.Errorf("expected exactly one stack= in output, got %q", output) - } - if !strings.Contains(output, "stack=\"custom.Stack\"") { - t.Errorf("expected custom stack, got %q", output) - } -} - -func TestNew_RotationFactory_Good(t *testing.T) { - var buf bytes.Buffer - // Set up a mock rotation writer factory - original := RotationWriterFactory - defer func() { RotationWriterFactory = original }() - - RotationWriterFactory = func(opts RotationOptions) goio.WriteCloser { - return nopWriteCloser{&buf} - } - - l := New(Options{ - Level: LevelInfo, - Rotation: &RotationOptions{Filename: "test.log"}, - }) - - l.Info("rotated message") - if buf.Len() == 0 { - t.Error("expected output via rotation writer") - } -} - -func TestNew_RotationFactory_Good_DefaultRetentionValues(t *testing.T) { - original := RotationWriterFactory - defer func() { RotationWriterFactory = original }() - - var captured RotationOptions - RotationWriterFactory = func(opts RotationOptions) goio.WriteCloser { - captured = opts - return nopWriteCloser{goio.Discard} - } - - _ = New(Options{ - Level: LevelInfo, - Rotation: &RotationOptions{Filename: "test.log"}, - }) - - if captured.MaxSize != defaultRotationMaxSize { - t.Errorf("expected default MaxSize=%d, got %d", defaultRotationMaxSize, captured.MaxSize) - } - if captured.MaxAge != defaultRotationMaxAge { - t.Errorf("expected default MaxAge=%d, got %d", defaultRotationMaxAge, captured.MaxAge) - } - if captured.MaxBackups != defaultRotationMaxBackups { - t.Errorf("expected default MaxBackups=%d, got %d", defaultRotationMaxBackups, captured.MaxBackups) - } -} - -func TestNew_DefaultOutput_Good(t *testing.T) { - // No output or rotation — should default to stderr (not nil) - l := New(Options{Level: LevelInfo}) - if l.output == nil { - t.Error("expected non-nil output when no Output specified") - } -} - -func TestNew_Bad_InvalidLevelDefaultsToInfo(t *testing.T) { - l := New(Options{Level: Level(99)}) - if l.Level() != LevelInfo { - t.Errorf("expected invalid level to default to info, got %v", l.Level()) - } -} - -func TestUsername_Good(t *testing.T) { - name := Username() - if name == "" { - t.Error("expected Username to return a non-empty string") - } -} - -func TestDefault_Good(t *testing.T) { - if Default() == nil { - t.Error("expected default logger to exist") - } - - // All package-level proxy functions - var buf bytes.Buffer - l := New(Options{Level: LevelDebug, Output: &buf}) - SetDefault(l) - defer SetDefault(New(Options{Level: LevelInfo})) - - SetLevel(LevelDebug) - if l.Level() != LevelDebug { - t.Error("expected package-level SetLevel to work") - } - - SetRedactKeys("secret") - - Debug("debug msg") - Info("info msg") - Warn("warn msg") - Error("error msg") - Security("sec msg") - - output := buf.String() - for _, tag := range []string{"[DBG]", "[INF]", "[WRN]", "[ERR]", "[SEC]"} { - if !strings.Contains(output, tag) { - t.Errorf("expected %s in output, got %q", tag, output) - } - } -} - -func TestDefault_Bad_SetDefaultNilIgnored(t *testing.T) { - original := Default() - var buf bytes.Buffer - custom := New(Options{Level: LevelInfo, Output: &buf}) - SetDefault(custom) - defer SetDefault(original) - - SetDefault(nil) - - if Default() != custom { - t.Error("expected SetDefault(nil) to preserve the current default logger") - } -} - -func TestLogger_StyleHooks_Bad_NilHooksDoNotPanic(t *testing.T) { - var buf bytes.Buffer - l := New(Options{Level: LevelDebug, Output: &buf}) - l.StyleTimestamp = nil - l.StyleDebug = nil - l.StyleInfo = nil - l.StyleWarn = nil - l.StyleError = nil - l.StyleSecurity = nil - - defer func() { - if r := recover(); r != nil { - t.Fatalf("expected nil style hooks not to panic, got panic: %v", r) - } - }() - - l.Debug("debug") - l.Info("info") - l.Warn("warn") - l.Error("error") - l.Security("security") - - output := buf.String() - for _, tag := range []string{"[DBG]", "[INF]", "[WRN]", "[ERR]", "[SEC]"} { - if !strings.Contains(output, tag) { - t.Errorf("expected %s in output, got %q", tag, output) - } - } -} diff --git a/tests/cli/log/main.go b/tests/cli/log/main.go deleted file mode 100644 index 37af8a8..0000000 --- a/tests/cli/log/main.go +++ /dev/null @@ -1,198 +0,0 @@ -// AX-10 CLI driver for go-log. It exercises the public logging and structured -// error helpers without depending on the repository's unit test package. -// -// task -d tests/cli/log test -// go run ./tests/cli/log -package main - -import ( - "bytes" - "errors" - "fmt" - "io" - "os" - "strings" - "time" - - corelog "dappco.re/go/log" -) - -type nopWriteCloser struct { - io.Writer -} - -func (nopWriteCloser) Close() error { - return nil -} - -func main() { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run() error { - if err := verifyLoggerOutput(); err != nil { - return fmt.Errorf("logger output: %w", err) - } - if err := verifyStructuredErrors(); err != nil { - return fmt.Errorf("structured errors: %w", err) - } - if err := verifyDefaultLoggerAndRotationHooks(); err != nil { - return fmt.Errorf("default logger and rotation hooks: %w", err) - } - if username := corelog.Username(); username == "" { - return errors.New("username should not be empty") - } - return nil -} - -func verifyLoggerOutput() error { - var buf bytes.Buffer - logger := corelog.New(corelog.Options{ - Level: corelog.LevelDebug, - Output: &buf, - RedactKeys: []string{"secret"}, - }) - - retryAfter := 3 * time.Second - err := corelog.EWithRecovery("driver.inner", "temporary failure", errors.New("network"), true, &retryAfter, "retry later") - err = corelog.WrapCode(err, "AX10_TEMPORARY", "driver.outer", "outer failure") - - logger.Debug("debug event", "component", "ax-10") - logger.Info("login\nattempt", "user", "alice", "secret", "token\nvalue") - logger.Warn("slow path", "attempt", 2) - logger.Error("request failed", "err", err) - logger.Security("suspicious login", "ip", "127.0.0.1") - - output := buf.String() - for _, want := range []string{ - "[DBG] debug event", - "[INF] login\\nattempt", - "user=\"alice\"", - "secret=\"[REDACTED]\"", - "[WRN] slow path attempt=2", - "[ERR] request failed", - "err=driver.outer: outer failure [AX10_TEMPORARY]: driver.inner: temporary failure: network", - "op=\"driver.outer\"", - "stack=\"driver.outer -> driver.inner\"", - "retryable=true", - "retry_after_seconds=3", - "next_action=\"retry later\"", - "[SEC] suspicious login ip=\"127.0.0.1\"", - } { - if !strings.Contains(output, want) { - return fmt.Errorf("output missing %q in %q", want, output) - } - } - - if strings.Contains(output, "token\nvalue") { - return errors.New("log output contains an unescaped newline in a value") - } - if got := strings.Count(strings.TrimSpace(output), "\n"); got != 4 { - return fmt.Errorf("expected 5 log lines, got %d", got+1) - } - - var quiet bytes.Buffer - corelog.New(corelog.Options{Level: corelog.LevelQuiet, Output: &quiet}).Security("hidden") - if quiet.Len() != 0 { - return errors.New("quiet logger emitted security output") - } - - return nil -} - -func verifyStructuredErrors() error { - root := errors.New("root cause") - inner := corelog.WrapCode(root, "AX10_FAILURE", "driver.inner", "inner failed") - outer := corelog.Wrap(inner, "driver.outer", "outer failed") - - if !corelog.Is(outer, root) { - return errors.New("wrapped error should match root cause") - } - if got := corelog.Root(outer); got != root { - return fmt.Errorf("root = %v, want %v", got, root) - } - if got := corelog.ErrCode(outer); got != "AX10_FAILURE" { - return fmt.Errorf("error code = %q, want AX10_FAILURE", got) - } - if got := corelog.Op(outer); got != "driver.outer" { - return fmt.Errorf("op = %q, want driver.outer", got) - } - if got := corelog.Message(outer); got != "outer failed" { - return fmt.Errorf("message = %q, want outer failed", got) - } - if got := corelog.FormatStackTrace(outer); got != "driver.outer -> driver.inner" { - return fmt.Errorf("stack trace = %q", got) - } - - ops := make([]string, 0, 2) - for op := range corelog.AllOps(outer) { - ops = append(ops, op) - } - if strings.Join(ops, ",") != "driver.outer,driver.inner" { - return fmt.Errorf("ops = %v", ops) - } - - retryAfter := 5 * time.Second - retryable := corelog.WrapWithRecovery(root, "driver.retry", "retryable failure", true, &retryAfter, "retry with backoff") - if !corelog.IsRetryable(retryable) { - return errors.New("retryable error should report retryable") - } - gotRetryAfter, ok := corelog.RetryAfter(retryable) - if !ok || gotRetryAfter == nil || *gotRetryAfter != retryAfter { - return fmt.Errorf("retry after = %v, ok=%v", gotRetryAfter, ok) - } - if got := corelog.RecoveryAction(retryable); got != "retry with backoff" { - return fmt.Errorf("recovery action = %q", got) - } - - joined := corelog.Join(outer, corelog.NewCode("AX10_JOINED", "joined failure")) - var logErr *corelog.Err - if !corelog.As(joined, &logErr) { - return errors.New("joined error should expose a structured error") - } - if got := corelog.NewError("plain").Error(); got != "plain" { - return fmt.Errorf("new error = %q, want plain", got) - } - - return nil -} - -func verifyDefaultLoggerAndRotationHooks() error { - var defaultBuf bytes.Buffer - originalDefault := corelog.Default() - corelog.SetDefault(corelog.New(corelog.Options{Level: corelog.LevelInfo, Output: &defaultBuf})) - defer corelog.SetDefault(originalDefault) - - corelog.Info("default info", "ok", true) - if output := defaultBuf.String(); !strings.Contains(output, "[INF] default info ok=true") { - return fmt.Errorf("default logger output = %q", output) - } - - var rotated bytes.Buffer - originalFactory := corelog.RotationWriterFactory - corelog.RotationWriterFactory = func(opts corelog.RotationOptions) io.WriteCloser { - if opts.Filename != "ax10.log" { - return nopWriteCloser{Writer: io.Discard} - } - return nopWriteCloser{Writer: &rotated} - } - defer func() { - corelog.RotationWriterFactory = originalFactory - }() - - logger := corelog.New(corelog.Options{ - Level: corelog.LevelInfo, - Rotation: &corelog.RotationOptions{ - Filename: "ax10.log", - }, - }) - logger.Info("rotated output") - if output := rotated.String(); !strings.Contains(output, "[INF] rotated output") { - return fmt.Errorf("rotation writer output = %q", output) - } - - return nil -} diff --git a/tests/cli/log/main_test.go b/tests/cli/log/main_test.go deleted file mode 100644 index 7d1a1a6..0000000 --- a/tests/cli/log/main_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "bytes" - "testing" -) - -func TestAX7_WriteCloser_Close_Good(t *testing.T) { - var buf bytes.Buffer - closer := nopWriteCloser{Writer: &buf} - err := closer.Close() - if err != nil { - t.Fatalf("expected nil close error, got %v", err) - } -} - -func TestAX7_WriteCloser_Close_Bad(t *testing.T) { - closer := nopWriteCloser{} - err := closer.Close() - if err != nil { - t.Fatalf("zero-value closer should close without error, got %v", err) - } -} - -func TestAX7_WriteCloser_Close_Ugly(t *testing.T) { - closer := nopWriteCloser{} - first := closer.Close() - second := closer.Close() - if first != nil || second != nil { - t.Fatalf("repeated close should be nil, first=%v second=%v", first, second) - } -} diff --git a/v090_ax7_test.go b/v090_ax7_test.go deleted file mode 100644 index 22a6fc5..0000000 --- a/v090_ax7_test.go +++ /dev/null @@ -1,1300 +0,0 @@ -package log - -import ( - "bytes" - "fmt" - "io" - "slices" - "strings" - "testing" - "time" -) - -type ax7RotationWriter struct { - io.Writer - closed bool -} - -func (w *ax7RotationWriter) Close() error { - w.closed = true - return nil -} - -func ax7DefaultBuffer(t *testing.T) *bytes.Buffer { - t.Helper() - original := Default() - buf := &bytes.Buffer{} - SetDefault(New(Options{Level: LevelDebug, Output: buf})) - t.Cleanup(func() { SetDefault(original) }) - return buf -} - -func TestAX7_Username_Good(t *testing.T) { - t.Setenv("USER", "codex") - got := Username() - if got != "codex" { - t.Fatalf("want codex, got %q", got) - } -} - -func TestAX7_Username_Bad(t *testing.T) { - t.Setenv("USER", "") - t.Setenv("USERNAME", "fallback") - got := Username() - if got != "fallback" { - t.Fatalf("want fallback, got %q", got) - } -} - -func TestAX7_Username_Ugly(t *testing.T) { - t.Setenv("USER", "") - t.Setenv("USERNAME", "") - got := Username() - if got != "unknown" { - t.Fatalf("want unknown, got %q", got) - } -} - -func TestAX7_Level_String_Good(t *testing.T) { - got := LevelInfo.String() - want := "info" - if got != want { - t.Fatalf("want %q, got %q", want, got) - } -} - -func TestAX7_Level_String_Bad(t *testing.T) { - got := Level(99).String() - want := "unknown" - if got != want { - t.Fatalf("want %q, got %q", want, got) - } -} - -func TestAX7_Level_String_Ugly(t *testing.T) { - got := Level(-1).String() - want := "unknown" - if got != want { - t.Fatalf("want %q, got %q", want, got) - } -} - -func TestAX7_New_Good(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelDebug, Output: buf}) - logger.Debug("ready") - if !strings.Contains(buf.String(), "[DBG] ready") { - t.Fatalf("expected debug output, got %q", buf.String()) - } -} - -func TestAX7_New_Bad(t *testing.T) { - logger := New(Options{Level: Level(99), Output: io.Discard}) - got := logger.Level() - if got != LevelInfo { - t.Fatalf("want info for invalid level, got %v", got) - } -} - -func TestAX7_New_Ugly(t *testing.T) { - keys := []string{"secret"} - logger := New(Options{Level: LevelInfo, Output: io.Discard, RedactKeys: keys}) - keys[0] = "mutated" - if logger.redactKeys[0] != "secret" { - t.Fatalf("redaction keys must be cloned, got %v", logger.redactKeys) - } -} - -func TestAX7_Logger_SetLevel_Good(t *testing.T) { - logger := New(Options{Level: LevelInfo, Output: io.Discard}) - logger.SetLevel(LevelDebug) - got := logger.Level() - if got != LevelDebug { - t.Fatalf("want debug, got %v", got) - } -} - -func TestAX7_Logger_SetLevel_Bad(t *testing.T) { - logger := New(Options{Level: LevelDebug, Output: io.Discard}) - logger.SetLevel(Level(99)) - got := logger.Level() - if got != LevelInfo { - t.Fatalf("want invalid level to normalise to info, got %v", got) - } -} - -func TestAX7_Logger_SetLevel_Ugly(t *testing.T) { - logger := New(Options{Level: LevelDebug, Output: io.Discard}) - logger.SetLevel(LevelQuiet) - got := logger.Level() - if got != LevelQuiet { - t.Fatalf("want quiet, got %v", got) - } -} - -func TestAX7_Logger_Level_Good(t *testing.T) { - logger := New(Options{Level: LevelWarn, Output: io.Discard}) - got := logger.Level() - if got != LevelWarn { - t.Fatalf("want warn, got %v", got) - } -} - -func TestAX7_Logger_Level_Bad(t *testing.T) { - logger := New(Options{Level: Level(-5), Output: io.Discard}) - got := logger.Level() - if got != LevelInfo { - t.Fatalf("want invalid level to read as info, got %v", got) - } -} - -func TestAX7_Logger_Level_Ugly(t *testing.T) { - logger := New(Options{Level: LevelQuiet, Output: io.Discard}) - got := logger.Level() - if got != LevelQuiet { - t.Fatalf("want quiet, got %v", got) - } -} - -func TestAX7_Logger_SetOutput_Good(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelInfo, Output: io.Discard}) - logger.SetOutput(buf) - logger.Info("switched") - if !strings.Contains(buf.String(), "switched") { - t.Fatalf("expected output in replacement writer, got %q", buf.String()) - } -} - -func TestAX7_Logger_SetOutput_Bad(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelInfo, Output: buf}) - logger.SetOutput(nil) - if logger.output == nil || logger.output == buf { - t.Fatalf("nil output should install fallback writer, got %#v", logger.output) - } -} - -func TestAX7_Logger_SetOutput_Ugly(t *testing.T) { - first := &bytes.Buffer{} - second := &bytes.Buffer{} - logger := New(Options{Level: LevelInfo, Output: first}) - logger.SetOutput(second) - logger.Info("after") - if first.Len() != 0 || !strings.Contains(second.String(), "after") { - t.Fatalf("expected only second writer to receive output, first=%q second=%q", first.String(), second.String()) - } -} - -func TestAX7_Logger_SetRedactKeys_Good(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelInfo, Output: buf}) - logger.SetRedactKeys("secret") - logger.Info("login", "secret", "token") - if !strings.Contains(buf.String(), `secret="[REDACTED]"`) { - t.Fatalf("expected redaction, got %q", buf.String()) - } -} - -func TestAX7_Logger_SetRedactKeys_Bad(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelInfo, Output: buf, RedactKeys: []string{"secret"}}) - logger.SetRedactKeys() - logger.Info("login", "secret", "visible") - if !strings.Contains(buf.String(), `secret="visible"`) { - t.Fatalf("expected cleared redaction keys, got %q", buf.String()) - } -} - -func TestAX7_Logger_SetRedactKeys_Ugly(t *testing.T) { - keys := []string{"secret"} - logger := New(Options{Level: LevelInfo, Output: io.Discard}) - logger.SetRedactKeys(keys...) - keys[0] = "mutated" - if logger.redactKeys[0] != "secret" { - t.Fatalf("SetRedactKeys must clone input, got %v", logger.redactKeys) - } -} - -func TestAX7_Logger_Debug_Good(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelDebug, Output: buf}) - logger.Debug("debug", "agent", "codex") - if !strings.Contains(buf.String(), `[DBG] debug agent="codex"`) { - t.Fatalf("expected debug output, got %q", buf.String()) - } -} - -func TestAX7_Logger_Debug_Bad(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelInfo, Output: buf}) - logger.Debug("hidden") - if buf.Len() != 0 { - t.Fatalf("debug should be suppressed at info level, got %q", buf.String()) - } -} - -func TestAX7_Logger_Debug_Ugly(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelDebug, Output: buf}) - logger.StyleDebug = nil - logger.Debug("nil-style") - if !strings.Contains(buf.String(), "[DBG] nil-style") { - t.Fatalf("expected fallback debug style, got %q", buf.String()) - } -} - -func TestAX7_Logger_Info_Good(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelInfo, Output: buf}) - logger.Info("info", "ok", true) - if !strings.Contains(buf.String(), "[INF] info ok=true") { - t.Fatalf("expected info output, got %q", buf.String()) - } -} - -func TestAX7_Logger_Info_Bad(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelWarn, Output: buf}) - logger.Info("hidden") - if buf.Len() != 0 { - t.Fatalf("info should be suppressed at warn level, got %q", buf.String()) - } -} - -func TestAX7_Logger_Info_Ugly(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelInfo, Output: buf}) - logger.Info("line\nbreak", "key\tname", "value\r") - if !strings.Contains(buf.String(), `line\nbreak key\tname="value\r"`) { - t.Fatalf("expected escaped info output, got %q", buf.String()) - } -} - -func TestAX7_Logger_Warn_Good(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelWarn, Output: buf}) - logger.Warn("warn", "attempt", 2) - if !strings.Contains(buf.String(), "[WRN] warn attempt=2") { - t.Fatalf("expected warn output, got %q", buf.String()) - } -} - -func TestAX7_Logger_Warn_Bad(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelError, Output: buf}) - logger.Warn("hidden") - if buf.Len() != 0 { - t.Fatalf("warn should be suppressed at error level, got %q", buf.String()) - } -} - -func TestAX7_Logger_Warn_Ugly(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelWarn, Output: buf}) - logger.StyleWarn = func(s string) string { return "WARN:" + s } - logger.Warn("styled") - if !strings.Contains(buf.String(), "WARN:[WRN] styled") { - t.Fatalf("expected styled warn output, got %q", buf.String()) - } -} - -func TestAX7_Logger_Error_Good(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelError, Output: buf}) - logger.Error("failed", "err", NewError("boom")) - if !strings.Contains(buf.String(), "[ERR] failed err=boom") { - t.Fatalf("expected error output, got %q", buf.String()) - } -} - -func TestAX7_Logger_Error_Bad(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelQuiet, Output: buf}) - logger.Error("hidden") - if buf.Len() != 0 { - t.Fatalf("error should be suppressed at quiet level, got %q", buf.String()) - } -} - -func TestAX7_Logger_Error_Ugly(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelError, Output: buf}) - err := Wrap(E("inner.Op", "inner failed", NewError("root")), "outer.Op", "outer failed") - logger.Error("failed", "err", err) - if !strings.Contains(buf.String(), `stack="outer.Op -> inner.Op"`) { - t.Fatalf("expected stack context, got %q", buf.String()) - } -} - -func TestAX7_Logger_Security_Good(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelError, Output: buf}) - logger.Security("login", "user", "alice") - if !strings.Contains(buf.String(), `[SEC] login user="alice"`) { - t.Fatalf("expected security output, got %q", buf.String()) - } -} - -func TestAX7_Logger_Security_Bad(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelQuiet, Output: buf}) - logger.Security("hidden") - if buf.Len() != 0 { - t.Fatalf("security should be suppressed at quiet level, got %q", buf.String()) - } -} - -func TestAX7_Logger_Security_Ugly(t *testing.T) { - buf := &bytes.Buffer{} - logger := New(Options{Level: LevelError, Output: buf}) - logger.StyleSecurity = func(s string) string { return "SEC:" + s } - logger.Security("styled") - if !strings.Contains(buf.String(), "SEC:[SEC] styled") { - t.Fatalf("expected styled security output, got %q", buf.String()) - } -} - -func TestAX7_Default_Good(t *testing.T) { - got := Default() - if got == nil { - t.Fatal("expected default logger") - } - if got.Level() < LevelQuiet || got.Level() > LevelDebug { - t.Fatalf("default logger level out of range: %v", got.Level()) - } -} - -func TestAX7_Default_Bad(t *testing.T) { - original := Default() - SetDefault(nil) - got := Default() - if got != original { - t.Fatal("SetDefault(nil) must preserve the existing default logger") - } -} - -func TestAX7_Default_Ugly(t *testing.T) { - original := Default() - custom := New(Options{Level: LevelDebug, Output: io.Discard}) - SetDefault(custom) - t.Cleanup(func() { SetDefault(original) }) - if Default() != custom { - t.Fatal("expected custom default logger") - } -} - -func TestAX7_SetDefault_Good(t *testing.T) { - original := Default() - custom := New(Options{Level: LevelWarn, Output: io.Discard}) - SetDefault(custom) - t.Cleanup(func() { SetDefault(original) }) - if Default() != custom { - t.Fatal("expected SetDefault to install custom logger") - } -} - -func TestAX7_SetDefault_Bad(t *testing.T) { - original := Default() - SetDefault(nil) - got := Default() - if got != original { - t.Fatal("nil default logger should be ignored") - } -} - -func TestAX7_SetDefault_Ugly(t *testing.T) { - original := Default() - first := New(Options{Level: LevelInfo, Output: io.Discard}) - second := New(Options{Level: LevelDebug, Output: io.Discard}) - SetDefault(first) - SetDefault(second) - t.Cleanup(func() { SetDefault(original) }) - if Default() != second { - t.Fatal("latest non-nil default logger should win") - } -} - -func TestAX7_SetLevel_Good(t *testing.T) { - original := Default() - logger := New(Options{Level: LevelInfo, Output: io.Discard}) - SetDefault(logger) - t.Cleanup(func() { SetDefault(original) }) - SetLevel(LevelDebug) - if logger.Level() != LevelDebug { - t.Fatalf("want debug, got %v", logger.Level()) - } -} - -func TestAX7_SetLevel_Bad(t *testing.T) { - original := Default() - logger := New(Options{Level: LevelDebug, Output: io.Discard}) - SetDefault(logger) - t.Cleanup(func() { SetDefault(original) }) - SetLevel(Level(99)) - if logger.Level() != LevelInfo { - t.Fatalf("invalid default level should normalise to info, got %v", logger.Level()) - } -} - -func TestAX7_SetLevel_Ugly(t *testing.T) { - original := Default() - logger := New(Options{Level: LevelDebug, Output: io.Discard}) - SetDefault(logger) - t.Cleanup(func() { SetDefault(original) }) - SetLevel(LevelQuiet) - if logger.Level() != LevelQuiet { - t.Fatalf("want quiet, got %v", logger.Level()) - } -} - -func TestAX7_SetRedactKeys_Good(t *testing.T) { - buf := ax7DefaultBuffer(t) - SetRedactKeys("token") - Info("login", "token", "abc") - if !strings.Contains(buf.String(), `token="[REDACTED]"`) { - t.Fatalf("expected package redaction, got %q", buf.String()) - } -} - -func TestAX7_SetRedactKeys_Bad(t *testing.T) { - buf := ax7DefaultBuffer(t) - SetRedactKeys("token") - SetRedactKeys() - Info("login", "token", "abc") - if !strings.Contains(buf.String(), `token="abc"`) { - t.Fatalf("expected redaction keys to clear, got %q", buf.String()) - } -} - -func TestAX7_SetRedactKeys_Ugly(t *testing.T) { - buf := ax7DefaultBuffer(t) - SetRedactKeys("") - Info("empty-key", "", "secret") - if !strings.Contains(buf.String(), `="[REDACTED]"`) { - t.Fatalf("expected empty key redaction, got %q", buf.String()) - } -} - -func TestAX7_Debug_Good(t *testing.T) { - buf := ax7DefaultBuffer(t) - Debug("debug", "agent", "codex") - if !strings.Contains(buf.String(), `[DBG] debug agent="codex"`) { - t.Fatalf("expected package debug output, got %q", buf.String()) - } -} - -func TestAX7_Debug_Bad(t *testing.T) { - buf := &bytes.Buffer{} - original := Default() - SetDefault(New(Options{Level: LevelInfo, Output: buf})) - t.Cleanup(func() { SetDefault(original) }) - Debug("hidden") - if buf.Len() != 0 { - t.Fatalf("debug should be suppressed by default info level, got %q", buf.String()) - } -} - -func TestAX7_Debug_Ugly(t *testing.T) { - buf := ax7DefaultBuffer(t) - Debug("control\nchars", "key", "value\t") - if !strings.Contains(buf.String(), `control\nchars key="value\t"`) { - t.Fatalf("expected escaped package debug output, got %q", buf.String()) - } -} - -func TestAX7_Info_Good(t *testing.T) { - buf := ax7DefaultBuffer(t) - Info("info", "ok", true) - if !strings.Contains(buf.String(), "[INF] info ok=true") { - t.Fatalf("expected package info output, got %q", buf.String()) - } -} - -func TestAX7_Info_Bad(t *testing.T) { - buf := &bytes.Buffer{} - original := Default() - SetDefault(New(Options{Level: LevelWarn, Output: buf})) - t.Cleanup(func() { SetDefault(original) }) - Info("hidden") - if buf.Len() != 0 { - t.Fatalf("info should be suppressed at warn level, got %q", buf.String()) - } -} - -func TestAX7_Info_Ugly(t *testing.T) { - buf := ax7DefaultBuffer(t) - Info("odd", "lonely") - if !strings.Contains(buf.String(), "lonely=") { - t.Fatalf("expected odd keyvals to render nil value, got %q", buf.String()) - } -} - -func TestAX7_Warn_Good(t *testing.T) { - buf := ax7DefaultBuffer(t) - Warn("warn", "attempt", 2) - if !strings.Contains(buf.String(), "[WRN] warn attempt=2") { - t.Fatalf("expected package warn output, got %q", buf.String()) - } -} - -func TestAX7_Warn_Bad(t *testing.T) { - buf := &bytes.Buffer{} - original := Default() - SetDefault(New(Options{Level: LevelError, Output: buf})) - t.Cleanup(func() { SetDefault(original) }) - Warn("hidden") - if buf.Len() != 0 { - t.Fatalf("warn should be suppressed at error level, got %q", buf.String()) - } -} - -func TestAX7_Warn_Ugly(t *testing.T) { - buf := ax7DefaultBuffer(t) - Warn("newline", "value", "a\nb") - if !strings.Contains(buf.String(), `value="a\nb"`) { - t.Fatalf("expected escaped package warn output, got %q", buf.String()) - } -} - -func TestAX7_Error_Good(t *testing.T) { - buf := ax7DefaultBuffer(t) - Error("failed", "err", NewError("boom")) - if !strings.Contains(buf.String(), "[ERR] failed err=boom") { - t.Fatalf("expected package error output, got %q", buf.String()) - } -} - -func TestAX7_Error_Bad(t *testing.T) { - buf := &bytes.Buffer{} - original := Default() - SetDefault(New(Options{Level: LevelQuiet, Output: buf})) - t.Cleanup(func() { SetDefault(original) }) - Error("hidden") - if buf.Len() != 0 { - t.Fatalf("error should be suppressed at quiet level, got %q", buf.String()) - } -} - -func TestAX7_Error_Ugly(t *testing.T) { - buf := ax7DefaultBuffer(t) - err := E("op.Name", "failed", NewError("root")) - Error("failed", "err", err) - if !strings.Contains(buf.String(), `op="op.Name"`) { - t.Fatalf("expected operation context, got %q", buf.String()) - } -} - -func TestAX7_Security_Good(t *testing.T) { - buf := ax7DefaultBuffer(t) - Security("login", "user", "alice") - if !strings.Contains(buf.String(), `[SEC] login user="alice"`) { - t.Fatalf("expected package security output, got %q", buf.String()) - } -} - -func TestAX7_Security_Bad(t *testing.T) { - buf := &bytes.Buffer{} - original := Default() - SetDefault(New(Options{Level: LevelQuiet, Output: buf})) - t.Cleanup(func() { SetDefault(original) }) - Security("hidden") - if buf.Len() != 0 { - t.Fatalf("security should be suppressed at quiet level, got %q", buf.String()) - } -} - -func TestAX7_Security_Ugly(t *testing.T) { - buf := ax7DefaultBuffer(t) - Security("line\nbreak", "ip", "127.0.0.1") - if !strings.Contains(buf.String(), `[SEC] line\nbreak ip="127.0.0.1"`) { - t.Fatalf("expected escaped package security output, got %q", buf.String()) - } -} - -func TestAX7_Err_Error_Good(t *testing.T) { - err := &Err{Op: "agent.Dispatch", Msg: "failed", Err: NewError("root"), Code: "agent.failed"} - got := err.Error() - if got != "agent.Dispatch: failed [agent.failed]: root" { - t.Fatalf("unexpected error string: %q", got) - } -} - -func TestAX7_Err_Error_Bad(t *testing.T) { - err := &Err{} - got := err.Error() - if got != "" { - t.Fatalf("empty Err should render empty string, got %q", got) - } -} - -func TestAX7_Err_Error_Ugly(t *testing.T) { - var err *Err - got := err.Error() - if got != "" { - t.Fatalf("nil Err should render empty string, got %q", got) - } -} - -func TestAX7_Err_Unwrap_Good(t *testing.T) { - root := NewError("root") - err := &Err{Err: root} - got := err.Unwrap() - if got != root { - t.Fatalf("want root, got %v", got) - } -} - -func TestAX7_Err_Unwrap_Bad(t *testing.T) { - err := &Err{} - got := err.Unwrap() - if got != nil { - t.Fatalf("empty Err should unwrap to nil, got %v", got) - } -} - -func TestAX7_Err_Unwrap_Ugly(t *testing.T) { - root := NewError("root") - err := &Err{Err: Wrap(root, "outer", "failed")} - if !Is(err.Unwrap(), root) { - t.Fatal("unwrap should preserve wrapped root") - } -} - -func TestAX7_E_Good(t *testing.T) { - root := NewError("root") - err := E("agent.Dispatch", "failed", root) - if !Is(err, root) || Op(err) != "agent.Dispatch" || Message(err) != "failed" { - t.Fatalf("unexpected structured error: %v", err) - } -} - -func TestAX7_E_Bad(t *testing.T) { - err := E("", "", nil) - got := err.Error() - if got != "" { - t.Fatalf("empty E should render empty string, got %q", got) - } -} - -func TestAX7_E_Ugly(t *testing.T) { - err := E("agent.Dispatch", "", NewError("root")) - got := err.Error() - if got != "agent.Dispatch: root" { - t.Fatalf("unexpected E edge rendering: %q", got) - } -} - -func TestAX7_EWithRecovery_Good(t *testing.T) { - retryAfter := 5 * time.Second - err := EWithRecovery("agent.Dispatch", "retry", nil, true, &retryAfter, "retry later") - if !IsRetryable(err) || RecoveryAction(err) != "retry later" { - t.Fatalf("expected recovery metadata, got %v", err) - } -} - -func TestAX7_EWithRecovery_Bad(t *testing.T) { - err := EWithRecovery("agent.Dispatch", "permanent", nil, false, nil, "") - got, ok := RetryAfter(err) - if IsRetryable(err) || ok || got != nil { - t.Fatalf("expected no retry hints, got retryable=%v retryAfter=%v ok=%v", IsRetryable(err), got, ok) - } -} - -func TestAX7_EWithRecovery_Ugly(t *testing.T) { - retryAfter := 10 * time.Millisecond - err := EWithRecovery("", "", NewError("root"), true, &retryAfter, "") - got, ok := RetryAfter(err) - if !ok || got == nil || *got != retryAfter { - t.Fatalf("expected retry-after edge metadata, got %v ok=%v", got, ok) - } -} - -func TestAX7_Wrap_Good(t *testing.T) { - root := NewError("root") - err := Wrap(root, "agent.Dispatch", "failed") - if !Is(err, root) || Op(err) != "agent.Dispatch" { - t.Fatalf("expected wrapped root with op, got %v", err) - } -} - -func TestAX7_Wrap_Bad(t *testing.T) { - err := Wrap(nil, "agent.Dispatch", "failed") - if err != nil { - t.Fatalf("nil wrap should return nil, got %v", err) - } - if Root(err) != nil { - t.Fatalf("nil wrapped root should stay nil") - } -} - -func TestAX7_Wrap_Ugly(t *testing.T) { - inner := NewCode("agent.failed", "root") - err := Wrap(inner, "agent.Dispatch", "failed") - if ErrCode(err) != "agent.failed" { - t.Fatalf("expected inherited code, got %q", ErrCode(err)) - } -} - -func TestAX7_WrapWithRecovery_Good(t *testing.T) { - retryAfter := time.Second - err := WrapWithRecovery(NewError("root"), "agent.Dispatch", "failed", true, &retryAfter, "retry") - if !IsRetryable(err) || RecoveryAction(err) != "retry" { - t.Fatalf("expected explicit recovery metadata, got %v", err) - } -} - -func TestAX7_WrapWithRecovery_Bad(t *testing.T) { - err := WrapWithRecovery(nil, "agent.Dispatch", "failed", true, nil, "retry") - if err != nil { - t.Fatalf("nil WrapWithRecovery should return nil, got %v", err) - } - if IsRetryable(err) { - t.Fatal("nil error must not be retryable") - } -} - -func TestAX7_WrapWithRecovery_Ugly(t *testing.T) { - innerDelay := time.Second - outerDelay := 2 * time.Second - inner := NewCodeWithRecovery("inner", "root", true, &innerDelay, "inner") - err := WrapWithRecovery(inner, "outer", "failed", false, &outerDelay, "outer") - got, _ := RetryAfter(err) - if !IsRetryable(err) || got == nil || *got != outerDelay || RecoveryAction(err) != "outer" { - t.Fatalf("outer hints should win while inner retryability remains visible, err=%v retryAfter=%v", err, got) - } -} - -func TestAX7_WrapCode_Good(t *testing.T) { - root := NewError("root") - err := WrapCode(root, "agent.failed", "agent.Dispatch", "failed") - if ErrCode(err) != "agent.failed" || !Is(err, root) { - t.Fatalf("expected coded wrapper, got %v", err) - } -} - -func TestAX7_WrapCode_Bad(t *testing.T) { - err := WrapCode(nil, "", "agent.Dispatch", "failed") - if err != nil { - t.Fatalf("nil error and empty code should return nil, got %v", err) - } - if ErrCode(err) != "" { - t.Fatalf("nil coded error should have empty code") - } -} - -func TestAX7_WrapCode_Ugly(t *testing.T) { - err := WrapCode(nil, "agent.failed", "agent.Dispatch", "failed") - if err == nil || ErrCode(err) != "agent.failed" { - t.Fatalf("code-only WrapCode should create coded error, got %v", err) - } -} - -func TestAX7_WrapCodeWithRecovery_Good(t *testing.T) { - retryAfter := time.Second - err := WrapCodeWithRecovery(NewError("root"), "agent.failed", "agent.Dispatch", "failed", true, &retryAfter, "retry") - if ErrCode(err) != "agent.failed" || !IsRetryable(err) { - t.Fatalf("expected coded retryable error, got %v", err) - } -} - -func TestAX7_WrapCodeWithRecovery_Bad(t *testing.T) { - err := WrapCodeWithRecovery(nil, "", "agent.Dispatch", "failed", true, nil, "retry") - if err != nil { - t.Fatalf("nil error and empty code should return nil, got %v", err) - } - if RecoveryAction(err) != "" { - t.Fatal("nil error must not expose recovery action") - } -} - -func TestAX7_WrapCodeWithRecovery_Ugly(t *testing.T) { - retryAfter := 3 * time.Second - err := WrapCodeWithRecovery(nil, "agent.failed", "", "", true, &retryAfter, "") - got, ok := RetryAfter(err) - if err == nil || !ok || got == nil || *got != retryAfter { - t.Fatalf("code-only recovery wrapper lost retry-after: err=%v got=%v ok=%v", err, got, ok) - } -} - -func TestAX7_NewCode_Good(t *testing.T) { - err := NewCode("agent.failed", "dispatch failed") - if ErrCode(err) != "agent.failed" || Message(err) != "dispatch failed" { - t.Fatalf("unexpected coded error: %v", err) - } -} - -func TestAX7_NewCode_Bad(t *testing.T) { - err := NewCode("", "dispatch failed") - got := ErrCode(err) - if got != "" { - t.Fatalf("empty code should stay empty, got %q", got) - } -} - -func TestAX7_NewCode_Ugly(t *testing.T) { - err := NewCode("", "") - got := err.Error() - if got != "" { - t.Fatalf("empty coded error should render empty, got %q", got) - } -} - -func TestAX7_NewCodeWithRecovery_Good(t *testing.T) { - retryAfter := time.Minute - err := NewCodeWithRecovery("agent.retry", "retry", true, &retryAfter, "retry") - if ErrCode(err) != "agent.retry" || !IsRetryable(err) { - t.Fatalf("expected retryable coded error, got %v", err) - } -} - -func TestAX7_NewCodeWithRecovery_Bad(t *testing.T) { - err := NewCodeWithRecovery("", "permanent", false, nil, "") - got, ok := RetryAfter(err) - if IsRetryable(err) || ok || got != nil { - t.Fatalf("expected no retry metadata, got retryAfter=%v ok=%v", got, ok) - } -} - -func TestAX7_NewCodeWithRecovery_Ugly(t *testing.T) { - err := NewCodeWithRecovery("", "", true, nil, "inspect") - if !IsRetryable(err) || RecoveryAction(err) != "inspect" { - t.Fatalf("expected recovery metadata without code/message, got %v", err) - } -} - -func TestAX7_RetryAfter_Good(t *testing.T) { - delay := 42 * time.Second - got, ok := RetryAfter(&Err{Msg: "retry", RetryAfter: &delay}) - if !ok || got == nil || *got != delay { - t.Fatalf("want retry-after %v, got %v ok=%v", delay, got, ok) - } -} - -func TestAX7_RetryAfter_Bad(t *testing.T) { - got, ok := RetryAfter(NewError("plain")) - if ok || got != nil { - t.Fatalf("plain error should not expose retry-after, got %v ok=%v", got, ok) - } - if retryableHint(NewError("plain")) { - t.Fatal("plain error should not be retryable") - } -} - -func TestAX7_RetryAfter_Ugly(t *testing.T) { - delay := time.Nanosecond - err := Wrap(&Err{Msg: "inner", RetryAfter: &delay}, "outer", "failed") - got, ok := RetryAfter(err) - if !ok || got == nil || *got != delay { - t.Fatalf("nested retry-after missing, got %v ok=%v", got, ok) - } -} - -func TestAX7_IsRetryable_Good(t *testing.T) { - err := &Err{Msg: "retry", Retryable: true} - got := IsRetryable(err) - if !got { - t.Fatal("expected retryable error") - } -} - -func TestAX7_IsRetryable_Bad(t *testing.T) { - err := &Err{Msg: "permanent"} - got := IsRetryable(err) - if got { - t.Fatal("non-retryable Err reported retryable") - } -} - -func TestAX7_IsRetryable_Ugly(t *testing.T) { - inner := &Err{Msg: "inner", Retryable: true} - err := fmt.Errorf("stdlib wrapper: %w", inner) - if !IsRetryable(err) { - t.Fatal("retryable metadata should be found through stdlib wrapper") - } -} - -func TestAX7_RecoveryAction_Good(t *testing.T) { - err := &Err{Msg: "recover", NextAction: "retry later"} - got := RecoveryAction(err) - if got != "retry later" { - t.Fatalf("want retry later, got %q", got) - } -} - -func TestAX7_RecoveryAction_Bad(t *testing.T) { - err := &Err{Msg: "no action"} - got := RecoveryAction(err) - if got != "" { - t.Fatalf("want empty recovery action, got %q", got) - } -} - -func TestAX7_RecoveryAction_Ugly(t *testing.T) { - inner := &Err{Msg: "inner", NextAction: "inspect"} - err := Wrap(inner, "outer", "failed") - if RecoveryAction(err) != "inspect" { - t.Fatalf("nested recovery action missing, got %q", RecoveryAction(err)) - } -} - -func TestAX7_Is_Good(t *testing.T) { - root := NewError("root") - err := Wrap(root, "outer", "failed") - if !Is(err, root) { - t.Fatal("wrapped error should match root") - } -} - -func TestAX7_Is_Bad(t *testing.T) { - left := NewError("left") - right := NewError("right") - if Is(left, right) { - t.Fatal("different errors should not match") - } -} - -func TestAX7_Is_Ugly(t *testing.T) { - got := Is(nil, nil) - if !got { - t.Fatal("errors.Is(nil, nil) should be true") - } -} - -func TestAX7_As_Good(t *testing.T) { - err := E("op", "msg", nil) - var got *Err - if !As(err, &got) || got.Op != "op" { - t.Fatalf("expected *Err with op, got %#v", got) - } -} - -func TestAX7_As_Bad(t *testing.T) { - err := NewError("plain") - var got *Err - if As(err, &got) || got != nil { - t.Fatalf("plain error should not match *Err, got %#v", got) - } -} - -func TestAX7_As_Ugly(t *testing.T) { - var got *Err - matched := As(nil, &got) - if matched || got != nil { - t.Fatalf("nil error should not match, matched=%v got=%#v", matched, got) - } -} - -func TestAX7_NewError_Good(t *testing.T) { - err := NewError("simple") - got := err.Error() - if got != "simple" { - t.Fatalf("want simple, got %q", got) - } -} - -func TestAX7_NewError_Bad(t *testing.T) { - err := NewError("") - got := err.Error() - if got != "" { - t.Fatalf("empty error should render empty, got %q", got) - } -} - -func TestAX7_NewError_Ugly(t *testing.T) { - err := NewError("line\nbreak") - got := err.Error() - if got != "line\nbreak" { - t.Fatalf("want newline-preserving error, got %q", got) - } -} - -func TestAX7_Join_Good(t *testing.T) { - left := NewError("left") - right := NewError("right") - err := Join(left, right) - if !Is(err, left) || !Is(err, right) { - t.Fatalf("joined error should match both inputs, got %v", err) - } -} - -func TestAX7_Join_Bad(t *testing.T) { - err := Join(nil, nil) - if err != nil { - t.Fatalf("joining nil errors should return nil, got %v", err) - } - if Root(err) != nil { - t.Fatal("nil join should have nil root") - } -} - -func TestAX7_Join_Ugly(t *testing.T) { - root := NewError("root") - err := Join(nil, root) - if !Is(err, root) { - t.Fatalf("join with nil should preserve real error, got %v", err) - } -} - -func TestAX7_Op_Good(t *testing.T) { - err := Wrap(E("inner", "failed", nil), "outer", "failed") - got := Op(err) - if got != "outer" { - t.Fatalf("want outer op, got %q", got) - } -} - -func TestAX7_Op_Bad(t *testing.T) { - err := NewError("plain") - got := Op(err) - if got != "" { - t.Fatalf("plain error should have empty op, got %q", got) - } -} - -func TestAX7_Op_Ugly(t *testing.T) { - got := Op(nil) - if got != "" { - t.Fatalf("nil error should have empty op, got %q", got) - } -} - -func TestAX7_ErrCode_Good(t *testing.T) { - err := Wrap(NewCode("agent.failed", "root"), "outer", "failed") - got := ErrCode(err) - if got != "agent.failed" { - t.Fatalf("want inherited code, got %q", got) - } -} - -func TestAX7_ErrCode_Bad(t *testing.T) { - err := E("op", "msg", nil) - got := ErrCode(err) - if got != "" { - t.Fatalf("uncoded error should have empty code, got %q", got) - } -} - -func TestAX7_ErrCode_Ugly(t *testing.T) { - got := ErrCode(nil) - if got != "" { - t.Fatalf("nil error should have empty code, got %q", got) - } -} - -func TestAX7_Message_Good(t *testing.T) { - err := E("op", "the message", NewError("root")) - got := Message(err) - if got != "the message" { - t.Fatalf("want structured message, got %q", got) - } -} - -func TestAX7_Message_Bad(t *testing.T) { - err := NewError("plain message") - got := Message(err) - if got != "plain message" { - t.Fatalf("plain error should return Error text, got %q", got) - } -} - -func TestAX7_Message_Ugly(t *testing.T) { - got := Message(nil) - if got != "" { - t.Fatalf("nil error should have empty message, got %q", got) - } -} - -func TestAX7_Root_Good(t *testing.T) { - root := NewError("root") - err := Wrap(Wrap(root, "inner", "failed"), "outer", "failed") - got := Root(err) - if got != root { - t.Fatalf("want root, got %v", got) - } -} - -func TestAX7_Root_Bad(t *testing.T) { - err := NewError("plain") - got := Root(err) - if got != err { - t.Fatalf("plain error should be its own root, got %v", got) - } -} - -func TestAX7_Root_Ugly(t *testing.T) { - left := NewError("left") - err := Join(left, NewError("right")) - got := Root(err) - if got != left { - t.Fatalf("joined root should follow first child, got %v", got) - } -} - -func TestAX7_AllOps_Good(t *testing.T) { - err := Wrap(E("inner", "failed", nil), "outer", "failed") - var ops []string - for op := range AllOps(err) { - ops = append(ops, op) - } - if !slices.Equal(ops, []string{"outer", "inner"}) { - t.Fatalf("unexpected ops: %v", ops) - } -} - -func TestAX7_AllOps_Bad(t *testing.T) { - var ops []string - for op := range AllOps(NewError("plain")) { - ops = append(ops, op) - } - if len(ops) != 0 { - t.Fatalf("plain error should have no ops, got %v", ops) - } -} - -func TestAX7_AllOps_Ugly(t *testing.T) { - err := Join(E("left", "failed", nil), E("right", "failed", nil)) - var ops []string - for op := range AllOps(err) { - ops = append(ops, op) - } - if !slices.Equal(ops, []string{"left", "right"}) { - t.Fatalf("joined ops should walk both branches, got %v", ops) - } -} - -func TestAX7_StackTrace_Good(t *testing.T) { - err := Wrap(E("inner", "failed", nil), "outer", "failed") - got := StackTrace(err) - if !slices.Equal(got, []string{"outer", "inner"}) { - t.Fatalf("unexpected stack trace: %v", got) - } -} - -func TestAX7_StackTrace_Bad(t *testing.T) { - got := StackTrace(NewError("plain")) - if len(got) != 0 { - t.Fatalf("plain error should have empty stack, got %v", got) - } - if FormatStackTrace(NewError("plain")) != "" { - t.Fatal("plain error should format as empty stack") - } -} - -func TestAX7_StackTrace_Ugly(t *testing.T) { - got := StackTrace(nil) - if len(got) != 0 { - t.Fatalf("nil error should have empty stack, got %v", got) - } - if FormatStackTrace(nil) != "" { - t.Fatal("nil error should format as empty stack") - } -} - -func TestAX7_FormatStackTrace_Good(t *testing.T) { - err := Wrap(E("inner", "failed", nil), "outer", "failed") - got := FormatStackTrace(err) - if got != "outer -> inner" { - t.Fatalf("want outer -> inner, got %q", got) - } -} - -func TestAX7_FormatStackTrace_Bad(t *testing.T) { - got := FormatStackTrace(NewError("plain")) - if got != "" { - t.Fatalf("plain error should have empty formatted stack, got %q", got) - } - if len(StackTrace(NewError("plain"))) != 0 { - t.Fatal("plain error should have empty stack slice") - } -} - -func TestAX7_FormatStackTrace_Ugly(t *testing.T) { - err := Join(E("left", "failed", nil), E("right", "failed", nil)) - got := FormatStackTrace(err) - if got != "left -> right" { - t.Fatalf("joined stack should include both branches, got %q", got) - } -} - -func TestAX7_LogError_Good(t *testing.T) { - buf := ax7DefaultBuffer(t) - root := NewError("root") - err := LogError(root, "agent.Dispatch", "failed") - if !Is(err, root) || !strings.Contains(buf.String(), "[ERR] failed") { - t.Fatalf("expected logged wrapped error, err=%v output=%q", err, buf.String()) - } -} - -func TestAX7_LogError_Bad(t *testing.T) { - buf := ax7DefaultBuffer(t) - err := LogError(nil, "agent.Dispatch", "failed") - if err != nil || buf.Len() != 0 { - t.Fatalf("nil LogError should return nil and not log, err=%v output=%q", err, buf.String()) - } -} - -func TestAX7_LogError_Ugly(t *testing.T) { - buf := ax7DefaultBuffer(t) - err := LogError(E("inner", "failed", NewError("root")), "outer", "failed") - if Op(err) != "outer" || !strings.Contains(buf.String(), `stack="inner"`) { - t.Fatalf("expected outer wrapped error and original stack log, err=%v output=%q", err, buf.String()) - } -} - -func TestAX7_LogWarn_Good(t *testing.T) { - buf := ax7DefaultBuffer(t) - root := NewError("root") - err := LogWarn(root, "cache.Get", "miss") - if !Is(err, root) || !strings.Contains(buf.String(), "[WRN] miss") { - t.Fatalf("expected logged warning, err=%v output=%q", err, buf.String()) - } -} - -func TestAX7_LogWarn_Bad(t *testing.T) { - buf := ax7DefaultBuffer(t) - err := LogWarn(nil, "cache.Get", "miss") - if err != nil || buf.Len() != 0 { - t.Fatalf("nil LogWarn should return nil and not log, err=%v output=%q", err, buf.String()) - } -} - -func TestAX7_LogWarn_Ugly(t *testing.T) { - buf := ax7DefaultBuffer(t) - err := LogWarn(NewCode("cache.miss", "miss"), "cache.Get", "fallback") - if ErrCode(err) != "cache.miss" || !strings.Contains(buf.String(), "[WRN] fallback") { - t.Fatalf("expected coded warning, err=%v output=%q", err, buf.String()) - } -} - -func TestAX7_Must_Good(t *testing.T) { - buf := ax7DefaultBuffer(t) - Must(nil, "startup", "ready") - if buf.Len() != 0 { - t.Fatalf("nil Must should not log, got %q", buf.String()) - } -} - -func TestAX7_Must_Bad(t *testing.T) { - buf := ax7DefaultBuffer(t) - didPanic := false - func() { - defer func() { didPanic = recover() != nil }() - Must(NewError("boom"), "startup", "failed") - }() - if !didPanic || !strings.Contains(buf.String(), "[ERR] failed") { - t.Fatalf("Must should panic after logging, panic=%v output=%q", didPanic, buf.String()) - } -} - -func TestAX7_Must_Ugly(t *testing.T) { - buf := ax7DefaultBuffer(t) - var recovered any - func() { - defer func() { recovered = recover() }() - Must(NewCode("startup.failed", "boom"), "startup", "failed") - }() - if ErrCode(recovered.(error)) != "startup.failed" || !strings.Contains(buf.String(), "[ERR] failed") { - t.Fatalf("Must should preserve code in panic, recovered=%v output=%q", recovered, buf.String()) - } -}