diff --git a/cmd/list.go b/cmd/list.go index d30f1d4..5397753 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -24,10 +24,9 @@ package cmd import ( "errors" "fmt" - "strings" - "text/tabwriter" "github.com/dgraph-io/badger/v4" + "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" ) @@ -58,16 +57,10 @@ func list(cmd *cobra.Command, args []string) error { targetDB = "@" + dbName } - delimiter, err := cmd.Flags().GetString("delimiter") - if err != nil { - return err - } - showSecrets, err := cmd.Flags().GetBool("secret") if err != nil { return err } - noKeys, err := cmd.Flags().GetBool("no-keys") if err != nil { return err @@ -80,36 +73,45 @@ func list(cmd *cobra.Command, args []string) error { if err != nil { return err } - binary, err := cmd.Flags().GetBool("binary") + noHeader, err := cmd.Flags().GetBool("no-header") if err != nil { return err } + includeBinary, err := cmd.Flags().GetBool("binary") + if err != nil { + return err + } + format, err := cmd.Flags().GetString("format") + if err != nil { + return err + } + switch format { + case "auto", "table", "tabular", "csv", "html", "markdown", "md": + default: + return fmt.Errorf("unsupported format %q", format) + } includeKey := !noKeys includeValue := !noValues if !includeKey && !includeValue && !showTTL { return fmt.Errorf("no columns selected; disable --no-keys/--no-values or pass --ttl") } - prefetchVals := includeValue columnKinds := selectColumns(includeKey, includeValue, showTTL) if len(columnKinds) == 0 { return fmt.Errorf("no columns selected; enable key, value, or ttl output") } - delimiterBytes := []byte(delimiter) - columnCount := len(columnKinds) - if len(delimiterBytes) > 0 && columnCount > 1 { - columnCount = columnCount*2 - 1 + tw := table.NewWriter() + tw.SetOutputMirror(cmd.OutOrStdout()) + configureListTable(tw) + + if !noHeader { + header := buildHeaderCells(columnKinds) + tw.AppendHeader(stringSliceToRow(header)) } - format := buildTabbedFormat(columnCount) - writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) - defer writer.Flush() - - placeholder := []byte("**********") - header := insertDelimiters(buildHeaderCells(columnKinds), delimiterBytes) - store.PrintTo(writer, format, false, header...) + placeholder := "[secret: pass --secret to view]" trans := TransactionArgs{ key: targetDB, @@ -118,16 +120,17 @@ func list(cmd *cobra.Command, args []string) error { transact: func(tx *badger.Txn, k []byte) error { opts := badger.DefaultIteratorOptions opts.PrefetchSize = 10 - opts.PrefetchValues = prefetchVals + opts.PrefetchValues = includeValue it := tx.NewIterator(opts) defer it.Close() var valueBuf []byte for it.Rewind(); it.Valid(); it.Next() { item := it.Item() - key := item.KeyCopy(nil) + key := string(item.KeyCopy(nil)) meta := item.UserMeta() isSecret := meta&metaSecret != 0 - valueBuf = valueBuf[:0] + + var valueStr string if includeValue && (!isSecret || showSecrets) { if err := item.Value(func(v []byte) error { valueBuf = append(valueBuf[:0], v...) @@ -135,8 +138,10 @@ func list(cmd *cobra.Command, args []string) error { }); err != nil { return err } + valueStr = store.FormatBytes(includeBinary, valueBuf) } - columns := make([][]byte, 0, len(columnKinds)) + + columns := make([]string, 0, len(columnKinds)) for _, column := range columnKinds { switch column { case columnKey: @@ -145,29 +150,44 @@ func list(cmd *cobra.Command, args []string) error { if isSecret && !showSecrets { columns = append(columns, placeholder) } else { - columns = append(columns, valueBuf) + columns = append(columns, valueStr) } case columnTTL: - columns = append(columns, []byte(formatExpiry(item.ExpiresAt()))) + columns = append(columns, formatExpiry(item.ExpiresAt())) } } - row := insertDelimiters(columns, delimiterBytes) - store.PrintTo(writer, format, binary, row...) + + tw.AppendRow(stringSliceToRow(columns)) } return nil }, } - return store.Transaction(trans) + if err := store.Transaction(trans); err != nil { + return err + } + + switch format { + case "auto", "table", "tabular": + tw.Render() + case "csv": + tw.RenderCSV() + case "html": + tw.RenderHTML() + case "markdown", "md": + tw.RenderMarkdown() + } + return nil } func init() { listCmd.Flags().BoolP("binary", "b", false, "include binary data in text output") - listCmd.Flags().StringP("delimiter", "d", "", "string inserted between columns") - listCmd.Flags().Bool("secret", false, "display values marked as secret") + listCmd.Flags().BoolP("secret", "S", false, "display values marked as secret") listCmd.Flags().Bool("no-keys", false, "suppress the key column") listCmd.Flags().Bool("no-values", false, "suppress the value column") - listCmd.Flags().Bool("ttl", false, "append a TTL column when entries expire") + listCmd.Flags().BoolP("ttl", "t", false, "append a TTL column when entries expire") + listCmd.Flags().Bool("no-header", false, "omit the header rows") + listCmd.Flags().StringP("format", "f", "table", "supports: table, csv, html, markdown") rootCmd.AddCommand(listCmd) } @@ -193,46 +213,32 @@ func selectColumns(includeKey, includeValue, showTTL bool) []columnKind { return columns } -func buildTabbedFormat(cols int) string { - if cols <= 0 { - return "\n" - } - var b strings.Builder - for i := 0; i < cols; i++ { - if i > 0 { - b.WriteByte('\t') - } - b.WriteString("%s") - } - b.WriteByte('\n') - return b.String() -} - -func insertDelimiters(columns [][]byte, delimiter []byte) [][]byte { - if len(delimiter) == 0 || len(columns) <= 1 { - return columns - } - out := make([][]byte, 0, len(columns)*2-1) - for i, col := range columns { - out = append(out, col) - if i < len(columns)-1 { - out = append(out, delimiter) - } - } - return out -} - -func buildHeaderCells(columnKinds []columnKind) [][]byte { - headers := make([][]byte, 0, len(columnKinds)) +func buildHeaderCells(columnKinds []columnKind) []string { + labels := make([]string, 0, len(columnKinds)) for _, column := range columnKinds { switch column { case columnKey: - headers = append(headers, []byte("Key")) + labels = append(labels, "Key") case columnValue: - headers = append(headers, []byte("Value")) + labels = append(labels, "Value") case columnTTL: - headers = append(headers, []byte("TTL")) + labels = append(labels, "TTL") } } - return headers + return labels +} + +func stringSliceToRow(values []string) table.Row { + row := make(table.Row, len(values)) + for i, val := range values { + row[i] = val + } + return row +} + +func configureListTable(tw table.Writer) { + tw.SetStyle(table.StyleColoredBright) + tw.SetColumnConfigs([]table.ColumnConfig{ + {Number: 2, WidthMax: 20}, + }) } diff --git a/cmd/shared.go b/cmd/shared.go index 803c286..5769153 100644 --- a/cmd/shared.go +++ b/cmd/shared.go @@ -98,15 +98,10 @@ func (s *Store) Print(pf string, includeBinary bool, vs ...[]byte) { } func (s *Store) PrintTo(w io.Writer, pf string, includeBinary bool, vs ...[]byte) { - nb := "(omitted binary data)" - fvs := make([]any, 0, len(vs)) tty := term.IsTerminal(int(os.Stdout.Fd())) + fvs := make([]any, 0, len(vs)) for _, v := range vs { - if tty && !includeBinary && !utf8.Valid(v) { - fvs = append(fvs, nb) - } else { - fvs = append(fvs, string(v)) - } + fvs = append(fvs, s.formatBytes(includeBinary, v)) } fmt.Fprintf(w, pf, fvs...) if w == os.Stdout && tty && !strings.HasSuffix(pf, "\n") { @@ -114,6 +109,18 @@ func (s *Store) PrintTo(w io.Writer, pf string, includeBinary bool, vs ...[]byte } } +func (s *Store) FormatBytes(includeBinary bool, v []byte) string { + return s.formatBytes(includeBinary, v) +} + +func (s *Store) formatBytes(includeBinary bool, v []byte) string { + tty := term.IsTerminal(int(os.Stdout.Fd())) + if tty && !includeBinary && !utf8.Valid(v) { + return "(omitted binary data)" + } + return string(v) +} + func (s *Store) AllStores() ([]string, error) { path, err := s.path() if err != nil { diff --git a/go.mod b/go.mod index 1592fd8..96014d8 100644 --- a/go.mod +++ b/go.mod @@ -12,9 +12,12 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jedib0t/go-pretty/v6 v6.7.0 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/muesli/go-app-paths v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.9 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect @@ -24,5 +27,6 @@ require ( golang.org/x/net v0.41.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.26.0 // indirect google.golang.org/protobuf v1.36.6 // indirect ) diff --git a/go.sum b/go.sum index 0302ed2..85fe61f 100644 --- a/go.sum +++ b/go.sum @@ -18,12 +18,19 @@ github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6F github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jedib0t/go-pretty/v6 v6.7.0 h1:DanoN1RnjXTwDN+B8yqtixXzXqNBCs2Vxo2ARsnrpsY= +github.com/jedib0t/go-pretty/v6 v6.7.0/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/muesli/go-app-paths v0.2.2 h1:NqG4EEZwNIhBq/pREgfBmgDmt3h1Smr1MjZiXbpZUnI= github.com/muesli/go-app-paths v0.2.2/go.mod h1:SxS3Umca63pcFcLtbjVb+J0oD7cl4ixQWoBKhGEtEho= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= @@ -45,6 +52,8 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=