From d09b6ea2aef27ea5cd41c23897b37868b561c1c3 Mon Sep 17 00:00:00 2001 From: IdanK Date: Sat, 1 Aug 2026 23:55:15 +0300 Subject: [PATCH] Update sdk --- go.mod | 5 ++ go.sum | 4 ++ style/render.go | 158 +++++++++++++++++++++++++++++++++++++++++++ style/style.go | 121 +++++++++++++++++++++++++++++++++ style/style_test.go | 161 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 449 insertions(+) create mode 100644 style/render.go create mode 100644 style/style.go create mode 100644 style/style_test.go diff --git a/go.mod b/go.mod index 7301ef8..f8d0bca 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,8 @@ module github.com/GoScouter/sdk go 1.26.4 require github.com/google/uuid v1.6.0 + +require ( + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect +) diff --git a/go.sum b/go.sum index 7790d7c..01b26d5 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= diff --git a/style/render.go b/style/render.go new file mode 100644 index 0000000..c6f281c --- /dev/null +++ b/style/render.go @@ -0,0 +1,158 @@ +package style + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +const indentWidth = 4 + +// Render turns an arbitrary JSON report into an indented, styled block of +// terminal text. It is the fallback view for modules that have no opinion on +// how their results should look: keys become labels, nested objects become +// headings, and array elements become bullets. +func Render(raw json.RawMessage) string { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + + var b strings.Builder + if err := value(&b, dec, "", 0); err != nil { + return Failuref("scan: unreadable report: %v", err) + "\r\n" + } + + return b.String() +} + +func value(b *strings.Builder, dec *json.Decoder, label string, depth int) error { + tok, err := dec.Token() + if err != nil { + return err + } + + if d, ok := tok.(json.Delim); ok { + switch d { + case '{': + return object(b, dec, label, depth) + case '[': + return array(b, dec, label, depth) + default: + return fmt.Errorf("unexpected %q", d) + } + } + + field(b, depth, label, scalar(tok)) + return nil +} + +func object(b *strings.Builder, dec *json.Decoder, label string, depth int) error { + inner := depth + if label != "" { + heading(b, depth, label) + inner++ + } + + for dec.More() { + key, err := dec.Token() + if err != nil { + return err + } + + name, ok := key.(string) + if !ok { + return fmt.Errorf("object key is %T, not a string", key) + } + + if err := value(b, dec, name, inner); err != nil { + return err + } + } + + _, err := dec.Token() // closing brace + return err +} + +func array(b *strings.Builder, dec *json.Decoder, label string, depth int) error { + titled := false + i := 0 + + for dec.More() { + tok, err := dec.Token() + if err != nil { + return err + } + + d, nested := tok.(json.Delim) + if !nested { + if !titled { + heading(b, depth, label) + titled = true + } + + bullet(b, depth+1, scalar(tok)) + i++ + continue + } + + element := fmt.Sprintf("%s[%d]", label, i) + b.WriteString("\r\n") + + switch d { + case '{': + err = object(b, dec, element, depth) + case '[': + err = array(b, dec, element, depth) + default: + err = fmt.Errorf("unexpected %q", d) + } + if err != nil { + return err + } + + i++ + } + + if i == 0 { + heading(b, depth, label) + } + + _, err := dec.Token() // closing bracket + return err +} + +func indent(depth int) string { + return strings.Repeat(" ", depth*indentWidth) +} + +func heading(b *strings.Builder, depth int, label string) { + b.WriteString(indent(depth) + Found(Bold(Cyan(label))) + "\r\n") +} + +func field(b *strings.Builder, depth int, label, val string) { + line := Gray(label + ":") + if val != "" { + line += " " + White(val) + } + + b.WriteString(indent(depth) + Found(line) + "\r\n") +} + +func bullet(b *strings.Builder, depth int, val string) { + b.WriteString(indent(depth) + Gray("- ") + White(val) + "\r\n") +} + +func scalar(tok json.Token) string { + switch v := tok.(type) { + case nil: + return "null" + case string: + return v + case json.Number: + return v.String() + case bool: + return fmt.Sprintf("%t", v) + default: + return fmt.Sprintf("%v", v) + } +} diff --git a/style/style.go b/style/style.go new file mode 100644 index 0000000..cf9cd48 --- /dev/null +++ b/style/style.go @@ -0,0 +1,121 @@ +package style + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + Reset = "\033[0m" + + CodeBold = "\033[1m" + CodeDim = "\033[2m" + + CodeRed = "\033[38;2;235;77;75m" + CodeGreen = "\033[38;2;111;207;151m" + CodeYellow = "\033[38;2;249;202;54m" + CodeCyan = "\033[38;2;56;193;208m" + CodeGray = "\033[38;2;130;130;150m" + CodePurple = "\033[38;2;87;87;232m" + CodeWhite = "\033[38;2;255;255;255m" +) + +func wrap(code, s string) string { + return code + s + Reset +} + +func Bold(s string) string { return wrap(CodeBold, s) } + +func BoldAll(s string) string { + return CodeBold + strings.ReplaceAll(s, Reset, Reset+CodeBold) + Reset +} + +func Dim(s string) string { return wrap(CodeDim, s) } +func Red(s string) string { return wrap(CodeRed, s) } +func Green(s string) string { return wrap(CodeGreen, s) } +func Yellow(s string) string { return wrap(CodeYellow, s) } +func Cyan(s string) string { return wrap(CodeCyan, s) } +func Gray(s string) string { return wrap(CodeGray, s) } +func Purple(s string) string { return wrap(CodePurple, s) } +func White(s string) string { return wrap(CodeWhite, s) } + +func Prompt() string { + return Dim("(") + Bold(Purple("gs")) + Dim(")") + " " + Cyan("❯") + " " +} + +func rawLines(msg string) string { + msg = strings.ReplaceAll(msg, "\r\n", "\n") + return strings.ReplaceAll(msg, "\n", "\r\n") +} + +func Error(msg string) string { + return Red("✗ ") + rawLines(msg) +} + +func Errorf(format string, a ...any) string { + return Error(fmt.Sprintf(format, a...)) +} + +func Success(msg string) string { + return Green("✓ ") + msg +} + +func Successf(format string, a ...any) string { + return Success(fmt.Sprintf(format, a...)) +} + +func Info(msg string) string { + return Cyan("» ") + msg +} + +func Infof(format string, a ...any) string { + return Info(fmt.Sprintf(format, a...)) +} + +var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func Width(s string) int { + return len([]rune(ansiRE.ReplaceAllString(s, ""))) +} + +func Found(msg string) string { + return Green("[+] ") + msg +} + +func Foundf(format string, a ...any) string { + return Found(fmt.Sprintf(format, a...)) +} + +func Failure(msg string) string { + return Red("[-] ") + msg +} + +func Failuref(format string, a ...any) string { + return Failure(fmt.Sprintf(format, a...)) +} + +func Alert(msg string) string { + return Yellow("[!] ") + rawLines(msg) +} + +func Alertf(format string, a ...any) string { + return Alert(fmt.Sprintf(format, a...)) +} + +func Section(title string, body ...string) string { + var b strings.Builder + + b.WriteString("\r\n") + b.WriteString(Bold(Cyan("["+title+"]")) + "\r\n") + for _, line := range body { + b.WriteString(line + "\r\n") + } + b.WriteString("\r\n") + + return b.String() +} + +func Field(label string, width int, value string) string { + return " " + Gray(fmt.Sprintf("%-*s", width, label)) + " " + value +} diff --git a/style/style_test.go b/style/style_test.go new file mode 100644 index 0000000..26d5edf --- /dev/null +++ b/style/style_test.go @@ -0,0 +1,161 @@ +package style + +import ( + "strings" + "testing" +) + +func withEnabled(t *testing.T, want bool, fn func()) { + t.Helper() + prev := enabled + enabled = want + defer func() { enabled = prev }() + fn() +} + +func TestDisabledIsPlain(t *testing.T) { + withEnabled(t, false, func() { + got := Red("boom") + if got != "boom" { + t.Fatalf("Red with styling off = %q, want plain %q", got, "boom") + } + if strings.Contains(Prompt(), "\033") { + t.Fatalf("Prompt emitted escape codes while styling disabled: %q", Prompt()) + } + }) +} + +func TestEnabledWraps(t *testing.T) { + withEnabled(t, true, func() { + got := Red("boom") + if !strings.HasPrefix(got, CodeRed) || !strings.HasSuffix(got, Reset) { + t.Fatalf("Red = %q, want wrapped in color + reset", got) + } + }) +} + +func TestSemanticPrefixes(t *testing.T) { + withEnabled(t, false, func() { + cases := map[string]string{ + "✗ ": Error("x"), + "✓ ": Success("x"), + "» ": Info("x"), + "[+] ": Found("x"), + "[-] ": Failure("x"), + "[!] ": Alert("x"), + } + for prefix, out := range cases { + if !strings.HasPrefix(out, prefix) { + t.Errorf("output %q missing prefix %q", out, prefix) + } + } + }) +} + +func TestWidthStripsANSI(t *testing.T) { + if got := Width("\x1b[31mabc\x1b[0m"); got != 3 { + t.Fatalf("Width of styled %q = %d, want 3", "abc", got) + } + if got := Width("héllo"); got != 5 { + t.Fatalf("Width of multibyte string = %d, want 5", got) + } +} + +func TestSectionBracketsTheTitle(t *testing.T) { + withEnabled(t, false, func() { + body := " A 1.2.3.4" + out := Section("DNS", body) + + lines := strings.Split(strings.TrimSpace(out), "\r\n") + if lines[0] != "[DNS]" { + t.Fatalf("first line = %q, want a bracketed title", lines[0]) + } + if lines[1] != body { + t.Fatalf("body line = %q, want it rendered verbatim", lines[1]) + } + }) +} + +func TestSectionPadsWithBlankLines(t *testing.T) { + withEnabled(t, false, func() { + out := Section("DNS") + + if !strings.HasPrefix(out, "\r\n") || !strings.HasSuffix(out, "\r\n\r\n") { + t.Fatalf("Section = %q, want it padded away from the surrounding output", out) + } + }) +} + +func TestFieldPadsLabel(t *testing.T) { + withEnabled(t, false, func() { + if got := Field("A", 6, "1.2.3.4"); got != " A 1.2.3.4" { + t.Fatalf("Field = %q, want the label padded into a column", got) + } + }) +} + +func TestRenderScalarsKeepTheirJSONForm(t *testing.T) { + withEnabled(t, false, func() { + got := Render([]byte(`{"host":"a.io","port":443,"up":true,"note":null}`)) + want := "[+] host: a.io\r\n[+] port: 443\r\n[+] up: true\r\n[+] note: null\r\n" + + if got != want { + t.Fatalf("Render = %q, want %q", got, want) + } + }) +} + +func TestRenderIndentsNestedObjectsAndBullets(t *testing.T) { + withEnabled(t, false, func() { + got := Render([]byte(`{"dns":{"a":["1.1.1.1","8.8.8.8"]}}`)) + want := "[+] dns\r\n" + + " [+] a\r\n" + + " - 1.1.1.1\r\n" + + " - 8.8.8.8\r\n" + + if got != want { + t.Fatalf("Render = %q, want each level indented one step further:\n%q", got, want) + } + }) +} + +func TestRenderNumbersLargerThanFloatPrecision(t *testing.T) { + withEnabled(t, false, func() { + // Decoding into any would round this through float64; UseNumber must not. + got := Render([]byte(`{"id":12345678901234567890}`)) + + if !strings.Contains(got, "12345678901234567890") { + t.Fatalf("Render = %q, want the number carried through verbatim", got) + } + }) +} + +func TestRenderIndexesArrayElements(t *testing.T) { + withEnabled(t, false, func() { + got := Render([]byte(`{"hosts":[{"name":"x"},{"name":"y"}]}`)) + + for _, want := range []string{"[+] hosts[0]", " [+] name: x", "[+] hosts[1]", " [+] name: y"} { + if !strings.Contains(got, want) { + t.Errorf("Render = %q, missing %q", got, want) + } + } + }) +} + +func TestRenderEmptyArrayKeepsItsHeading(t *testing.T) { + withEnabled(t, false, func() { + if got := Render([]byte(`{"empty":[]}`)); got != "[+] empty\r\n" { + t.Fatalf("Render = %q, want a bare heading for the empty array", got) + } + }) +} + +func TestRenderReportsTruncatedJSON(t *testing.T) { + withEnabled(t, false, func() { + got := Render([]byte(`{"broken":`)) + + if !strings.HasPrefix(got, "[-] ") || !strings.HasSuffix(got, "\r\n") { + t.Fatalf("Render = %q, want a failure line for unreadable input", got) + } + }) +}