feat(06-01): add Formatter interface, Registry, and TTY color detection
- pkg/output/formatter.go: Formatter interface, Options, Registry with Register/Get/Names, ErrUnknownFormat sentinel - pkg/output/colors.go: IsTTY + ColorsEnabled honoring NO_COLOR - Promote github.com/mattn/go-isatty to direct dependency - Unit tests cover registry round-trip, unknown lookup, sorted Names, non-TTY buffer, NO_COLOR override Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
go.mod
2
go.mod
@@ -6,6 +6,7 @@ require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/go-git/go-git/v5 v5.17.2
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/panjf2000/ants/v2 v2.12.0
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -43,7 +44,6 @@ require (
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
|
||||
34
pkg/output/colors.go
Normal file
34
pkg/output/colors.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
)
|
||||
|
||||
// IsTTY reports whether f is an open terminal or Cygwin/MSYS pty.
|
||||
// Returns false for a nil file.
|
||||
func IsTTY(f *os.File) bool {
|
||||
if f == nil {
|
||||
return false
|
||||
}
|
||||
fd := f.Fd()
|
||||
return isatty.IsTerminal(fd) || isatty.IsCygwinTerminal(fd)
|
||||
}
|
||||
|
||||
// ColorsEnabled reports whether ANSI color output should be emitted on w.
|
||||
// Returns false when:
|
||||
// - the NO_COLOR environment variable is set (https://no-color.org/), or
|
||||
// - w is not an *os.File (e.g. bytes.Buffer, strings.Builder), or
|
||||
// - w is an *os.File but not a terminal.
|
||||
func ColorsEnabled(w io.Writer) bool {
|
||||
if _, ok := os.LookupEnv("NO_COLOR"); ok {
|
||||
return false
|
||||
}
|
||||
f, ok := w.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return IsTTY(f)
|
||||
}
|
||||
37
pkg/output/colors_test.go
Normal file
37
pkg/output/colors_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestColorsEnabled_BufferIsNotTTY(t *testing.T) {
|
||||
t.Setenv("NO_COLOR", "")
|
||||
// Explicitly unset NO_COLOR so the non-TTY branch is exercised.
|
||||
t.Setenv("NO_COLOR", "")
|
||||
assert.False(t, ColorsEnabled(&bytes.Buffer{}), "bytes.Buffer must never be considered a TTY")
|
||||
}
|
||||
|
||||
func TestColorsEnabled_NoColorEnvForcesOff(t *testing.T) {
|
||||
t.Setenv("NO_COLOR", "1")
|
||||
assert.False(t, ColorsEnabled(&bytes.Buffer{}))
|
||||
}
|
||||
|
||||
func TestColorsEnabled_NilWriterSafe(t *testing.T) {
|
||||
t.Setenv("NO_COLOR", "")
|
||||
// A nil *os.File should return false, not panic.
|
||||
var f *nilWriter
|
||||
assert.False(t, ColorsEnabled(f))
|
||||
}
|
||||
|
||||
// nilWriter is a typed-nil io.Writer used to verify ColorsEnabled does not
|
||||
// panic when passed a non-*os.File writer.
|
||||
type nilWriter struct{}
|
||||
|
||||
func (*nilWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
func TestIsTTY_NilFile(t *testing.T) {
|
||||
assert.False(t, IsTTY(nil))
|
||||
}
|
||||
61
pkg/output/formatter.go
Normal file
61
pkg/output/formatter.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
|
||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
||||
)
|
||||
|
||||
// ErrUnknownFormat is returned by Get when no formatter is registered for the
|
||||
// requested name. Callers should use errors.Is to check for it.
|
||||
var ErrUnknownFormat = errors.New("output: unknown format")
|
||||
|
||||
// Options controls formatter behavior. Unmask reveals full key values.
|
||||
// ToolName and ToolVersion are consumed by metadata-bearing formats (SARIF).
|
||||
type Options struct {
|
||||
Unmask bool
|
||||
ToolName string
|
||||
ToolVersion string
|
||||
}
|
||||
|
||||
// Formatter renders a slice of findings to an io.Writer.
|
||||
// Implementations must not mutate the findings slice.
|
||||
type Formatter interface {
|
||||
Format(findings []engine.Finding, w io.Writer, opts Options) error
|
||||
}
|
||||
|
||||
// registry holds all formatters registered at init() time. It is not guarded
|
||||
// by a mutex because registration is expected to happen exclusively from
|
||||
// package init functions, which run sequentially before main.
|
||||
var registry = map[string]Formatter{}
|
||||
|
||||
// Register adds a formatter under the given name. Intended to be called from
|
||||
// package init() functions. Re-registering the same name overwrites the
|
||||
// previous value.
|
||||
func Register(name string, f Formatter) {
|
||||
registry[name] = f
|
||||
}
|
||||
|
||||
// Get returns the formatter registered under name. If no formatter is
|
||||
// registered, the returned error wraps ErrUnknownFormat.
|
||||
func Get(name string) (Formatter, error) {
|
||||
f, ok := registry[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownFormat, name)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Names returns the sorted list of registered format names. Used by the scan
|
||||
// command to build --output help text and error messages.
|
||||
func Names() []string {
|
||||
names := make([]string, 0, len(registry))
|
||||
for k := range registry {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
72
pkg/output/formatter_test.go
Normal file
72
pkg/output/formatter_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
||||
)
|
||||
|
||||
// fakeFormatter is a minimal Formatter used to verify the registry round-trip.
|
||||
type fakeFormatter struct{ tag string }
|
||||
|
||||
func (f fakeFormatter) Format(findings []engine.Finding, w io.Writer, opts Options) error {
|
||||
_, err := w.Write([]byte(f.tag))
|
||||
return err
|
||||
}
|
||||
|
||||
func TestFormatter_RegisterAndGet(t *testing.T) {
|
||||
Register("__test_fake__", fakeFormatter{tag: "ok"})
|
||||
t.Cleanup(func() { delete(registry, "__test_fake__") })
|
||||
|
||||
f, err := Get("__test_fake__")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, f)
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, f.Format(nil, &buf, Options{}))
|
||||
assert.Equal(t, "ok", buf.String())
|
||||
}
|
||||
|
||||
func TestFormatter_GetUnknown(t *testing.T) {
|
||||
_, err := Get("__definitely_not_registered__")
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Is(err, ErrUnknownFormat), "error should wrap ErrUnknownFormat")
|
||||
}
|
||||
|
||||
func TestFormatter_NamesSorted(t *testing.T) {
|
||||
Register("__z_fake__", fakeFormatter{})
|
||||
Register("__a_fake__", fakeFormatter{})
|
||||
t.Cleanup(func() {
|
||||
delete(registry, "__z_fake__")
|
||||
delete(registry, "__a_fake__")
|
||||
})
|
||||
|
||||
names := Names()
|
||||
// The full registry may contain other entries (e.g. "table"); just assert
|
||||
// that our two names appear in sorted order relative to each other.
|
||||
var aIdx, zIdx = -1, -1
|
||||
for i, n := range names {
|
||||
switch n {
|
||||
case "__a_fake__":
|
||||
aIdx = i
|
||||
case "__z_fake__":
|
||||
zIdx = i
|
||||
}
|
||||
}
|
||||
require.GreaterOrEqual(t, aIdx, 0)
|
||||
require.GreaterOrEqual(t, zIdx, 0)
|
||||
assert.Less(t, aIdx, zIdx, "Names() must return entries in ascending order")
|
||||
}
|
||||
|
||||
func TestOptions_Defaults(t *testing.T) {
|
||||
var opts Options
|
||||
assert.False(t, opts.Unmask)
|
||||
assert.Equal(t, "", opts.ToolName)
|
||||
assert.Equal(t, "", opts.ToolVersion)
|
||||
}
|
||||
Reference in New Issue
Block a user