feat: implement output formatting and display (HAN-383)
CI / test (pull_request) Failing after 2m2s

Add text and JSON formatters with real-time progress via traverser hooks,
summary statistics, and CLI integration for --show-* and --json flags.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Gary Hansen
2026-06-08 02:21:13 +10:00
co-authored by multica-agent
parent 876542b2de
commit 5a54834c5f
13 changed files with 1359 additions and 180 deletions
+31
View File
@@ -0,0 +1,31 @@
package traverse
type EventStage int
const (
EventStart EventStage = iota
EventComplete
)
type TraversalEvent struct {
Stage EventStage
Result TraversalResult
IsResolve bool
}
type EventHandler func(TraversalEvent)
type TraverserHooks struct {
OnEvent EventHandler
}
func (h *TraverserHooks) emit(stage EventStage, result TraversalResult, isResolve bool) {
if h == nil || h.OnEvent == nil {
return
}
h.OnEvent(TraversalEvent{
Stage: stage,
Result: result,
IsResolve: isResolve,
})
}
+52
View File
@@ -0,0 +1,52 @@
package traverse
import (
"context"
"net"
"testing"
"github.com/miekg/dns"
)
func TestTraverserHooksEmitEvents(t *testing.T) {
answerResp := func() *dns.Msg {
m := new(dns.Msg)
m.SetReply(new(dns.Msg))
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("93.184.216.34"),
})
return m
}()
var events []TraversalEvent
hooks := &TraverserHooks{
OnEvent: func(event TraversalEvent) {
events = append(events, event)
},
}
tr := NewTraverser(&TraverserConfig{
MaxDepth: 5,
QueryType: dns.TypeA,
RootAddrs: []net.IP{net.ParseIP("198.41.0.4")},
Hooks: hooks,
})
tr.SetExchange(func(ctx context.Context, server string, msg *dns.Msg, useTCP bool) (*dns.Msg, error) {
return answerResp.Copy(), nil
})
_, err := tr.Traverse(context.Background(), "example.com")
if err != nil {
t.Fatalf("Traverse: %v", err)
}
if len(events) < 2 {
t.Fatalf("expected start and complete events, got %d", len(events))
}
if events[0].Stage != EventStart {
t.Fatalf("first event stage = %v, want start", events[0].Stage)
}
if events[1].Stage != EventComplete {
t.Fatalf("second event stage = %v, want complete", events[1].Stage)
}
}
+27 -1
View File
@@ -16,6 +16,7 @@ type TraverserConfig struct {
RootConfig *dns.RootDiscoveryConfig
QueryConfig *dns.QueryConfig
RootAddrs []net.IP
Hooks *TraverserHooks
}
func DefaultTraverserConfig() *TraverserConfig {
@@ -57,6 +58,13 @@ func (t *Traverser) SetExchange(fn dns.ExchangeFunc) {
t.exchange = fn
}
func (t *Traverser) SetHooks(hooks *TraverserHooks) {
if t.config == nil {
t.config = DefaultTraverserConfig()
}
t.config.Hooks = hooks
}
func (t *Traverser) Traverse(ctx context.Context, name string) ([]TraversalResult, error) {
name = miekgdns.Fqdn(name)
@@ -94,9 +102,19 @@ func (t *Traverser) Traverse(ctx context.Context, name string) ([]TraversalResul
cache = rootCache.Child()
}
if t.config.Hooks != nil {
t.config.Hooks.emit(EventStart, TraversalResult{Referral: ref}, false)
}
resp := t.processReferral(ctx, ref, cache)
result := TraversalResult{Referral: ref, Response: resp}
if t.config.Hooks != nil {
t.config.Hooks.emit(EventComplete, result, false)
}
mu.Lock()
results = append(results, TraversalResult{Referral: ref, Response: resp})
results = append(results, result)
mu.Unlock()
if resp.IsTerminal() {
@@ -266,8 +284,16 @@ func (t *Traverser) ResolveNS(ctx context.Context, nsName string, cache *InfoCac
cacheForStep = traversalCache.Child()
}
if t.config.Hooks != nil {
t.config.Hooks.emit(EventStart, TraversalResult{Referral: current}, true)
}
resp := t.processReferral(ctx, current, cacheForStep)
if t.config.Hooks != nil {
t.config.Hooks.emit(EventComplete, TraversalResult{Referral: current, Response: resp}, true)
}
if resp.Type == RespAnswer && len(resp.Decoded.Answers) > 0 {
for _, rr := range resp.Decoded.Answers {
if a, ok := rr.(*miekgdns.A); ok {