From dd1c2ef3db615d74be52c0e9cd13ca4ff7c7df3f Mon Sep 17 00:00:00 2001 From: NightStar Date: Tue, 28 Jul 2026 15:54:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20output=20system=20=E2=80=94=20table/jso?= =?UTF-8?q?n/yaml/raw=20printers=20with=20struct=20tag=20driven=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 1 + go.sum | 3 + internal/output/json.go | 12 ++++ internal/output/output.go | 33 +++++++++ internal/output/output_test.go | 96 ++++++++++++++++++++++++++ internal/output/table.go | 120 +++++++++++++++++++++++++++++++++ internal/output/yaml.go | 13 ++++ 7 files changed, 278 insertions(+) create mode 100644 internal/output/json.go create mode 100644 internal/output/output.go create mode 100644 internal/output/output_test.go create mode 100644 internal/output/table.go create mode 100644 internal/output/yaml.go diff --git a/go.mod b/go.mod index 654f5f4..306b9c9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.18 require ( github.com/BurntSushi/toml v1.6.0 github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index c09a7f6..73408f2 100644 --- a/go.sum +++ b/go.sum @@ -9,4 +9,7 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/output/json.go b/internal/output/json.go new file mode 100644 index 0000000..6857e38 --- /dev/null +++ b/internal/output/json.go @@ -0,0 +1,12 @@ +package output + +import ( + "encoding/json" + "io" +) + +func printJSON(w io.Writer, data any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(data) +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..23efb1c --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,33 @@ +package output + +import ( + "fmt" + "io" +) + +type Printer struct { + format string + writer io.Writer +} + +func New(format string, w io.Writer) *Printer { + return &Printer{format: format, writer: w} +} + +func (p *Printer) Print(data any) error { + switch p.format { + case "table": + return printTable(p.writer, data) + case "json": + return printJSON(p.writer, data) + case "yaml": + return printYAML(p.writer, data) + default: + return fmt.Errorf("unknown output format: %s", p.format) + } +} + +func (p *Printer) PrintRaw(raw []byte) error { + _, err := fmt.Fprintln(p.writer, string(raw)) + return err +} diff --git a/internal/output/output_test.go b/internal/output/output_test.go new file mode 100644 index 0000000..56639b7 --- /dev/null +++ b/internal/output/output_test.go @@ -0,0 +1,96 @@ +package output + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +type testItem struct { + ID string `json:"id" table:"ID"` + Name string `json:"name" table:"NAME"` + Age int `json:"age" table:"AGE"` +} + +func TestTableFormat(t *testing.T) { + items := []testItem{ + {ID: "1", Name: "Alice", Age: 30}, + {ID: "2", Name: "Bob", Age: 25}, + } + var buf bytes.Buffer + printer := New("table", &buf) + if err := printer.Print(items); err != nil { + t.Fatalf("Print table error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "ID") || !strings.Contains(out, "Alice") { + t.Errorf("table output missing headers or data:\n%s", out) + } +} + +func TestJSONFormat(t *testing.T) { + items := []testItem{ + {ID: "1", Name: "Alice", Age: 30}, + } + var buf bytes.Buffer + printer := New("json", &buf) + if err := printer.Print(items); err != nil { + t.Fatalf("Print json error: %v", err) + } + var parsed []testItem + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatalf("invalid JSON output: %v", err) + } + if len(parsed) != 1 || parsed[0].ID != "1" { + t.Errorf("JSON content mismatch: %+v", parsed) + } +} + +func TestYAMLFormat(t *testing.T) { + items := []testItem{ + {ID: "1", Name: "Alice", Age: 30}, + } + var buf bytes.Buffer + printer := New("yaml", &buf) + if err := printer.Print(items); err != nil { + t.Fatalf("Print yaml error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "id:") || !strings.Contains(out, "Alice") { + t.Errorf("yaml output missing data:\n%s", out) + } +} + +func TestRawFormat(t *testing.T) { + raw := []byte(`{"raw":"data"}`) + var buf bytes.Buffer + printer := New("raw", &buf) + if err := printer.PrintRaw(raw); err != nil { + t.Fatalf("PrintRaw error: %v", err) + } + if buf.String() != `{"raw":"data"}`+"\n" { + t.Errorf("raw output = %q", buf.String()) + } +} + +func TestSingleItem(t *testing.T) { + item := testItem{ID: "1", Name: "Solo", Age: 99} + var buf bytes.Buffer + printer := New("json", &buf) + if err := printer.Print(item); err != nil { + t.Fatalf("Print single item error: %v", err) + } + if !strings.Contains(buf.String(), "Solo") { + t.Errorf("single item output missing data:\n%s", buf.String()) + } +} + +func TestUnknownFormat(t *testing.T) { + var buf bytes.Buffer + printer := New("unknown", &buf) + err := printer.Print([]testItem{}) + if err == nil { + t.Error("expected error for unknown format") + } +} diff --git a/internal/output/table.go b/internal/output/table.go new file mode 100644 index 0000000..c41b42f --- /dev/null +++ b/internal/output/table.go @@ -0,0 +1,120 @@ +package output + +import ( + "fmt" + "io" + "reflect" + "strings" +) + +func printTable(w io.Writer, data any) error { + v := reflect.ValueOf(data) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + if v.Kind() == reflect.Slice { + return printTableSlice(w, v) + } + return printTableSingle(w, v) +} + +func printTableSlice(w io.Writer, v reflect.Value) error { + if v.Len() == 0 { + fmt.Fprintln(w, "(empty)") + return nil + } + elemType := v.Index(0).Type() + if elemType.Kind() == reflect.Ptr { + elemType = elemType.Elem() + } + headers, cols := extractTableTags(elemType) + if len(headers) == 0 { + return fmt.Errorf("no table tags found on type %s", elemType.Name()) + } + + // Collect all rows + rows := make([][]string, v.Len()) + for i := 0; i < v.Len(); i++ { + rows[i] = extractRow(v.Index(i), cols) + } + + // Calculate column widths + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + for _, row := range rows { + for i, cell := range row { + if len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + + // Print headers + sep := " " + renderRow(w, headers, widths, sep) + + // Print separator line + separator := make([]string, len(headers)) + for i, ww := range widths { + separator[i] = strings.Repeat("-", ww) + } + renderRow(w, separator, widths, sep) + + // Print data rows + for _, row := range rows { + renderRow(w, row, widths, sep) + } + + return nil +} + +func renderRow(w io.Writer, cells []string, widths []int, sep string) { + parts := make([]string, len(cells)) + for i, cell := range cells { + parts[i] = fmt.Sprintf("%-*s", widths[i], cell) + } + fmt.Fprintln(w, strings.Join(parts, sep)) +} + +func printTableSingle(w io.Writer, v reflect.Value) error { + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + t := v.Type() + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("table") + if tag == "" { + continue + } + val := fmt.Sprintf("%v", v.Field(i).Interface()) + fmt.Fprintf(w, "%-10s %s\n", tag+":", val) + } + return nil +} + +func extractTableTags(t reflect.Type) ([]string, []int) { + var headers []string + var cols []int + for i := 0; i < t.NumField(); i++ { + tag := t.Field(i).Tag.Get("table") + if tag == "" { + continue + } + headers = append(headers, strings.ToUpper(tag)) + cols = append(cols, i) + } + return headers, cols +} + +func extractRow(v reflect.Value, cols []int) []string { + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + row := make([]string, len(cols)) + for i, idx := range cols { + row[i] = fmt.Sprintf("%v", v.Field(idx).Interface()) + } + return row +} diff --git a/internal/output/yaml.go b/internal/output/yaml.go new file mode 100644 index 0000000..ac43285 --- /dev/null +++ b/internal/output/yaml.go @@ -0,0 +1,13 @@ +package output + +import ( + "io" + + "gopkg.in/yaml.v3" +) + +func printYAML(w io.Writer, data any) error { + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + return enc.Encode(data) +}