refactor: consolidates all list files together

This commit is contained in:
Lewis Wynne 2026-02-10 22:17:55 +00:00
parent 4dff61074d
commit 34970ac9d9
3 changed files with 175 additions and 410 deletions

View file

@ -25,10 +25,50 @@ package cmd
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"os"
"strconv"
"github.com/dgraph-io/badger/v4" "github.com/dgraph-io/badger/v4"
"github.com/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/term"
)
// formatEnum implements pflag.Value for format selection.
type formatEnum string
func (e *formatEnum) String() string { return string(*e) }
func (e *formatEnum) Set(v string) error {
switch v {
case "table", "tsv", "csv", "html", "markdown":
*e = formatEnum(v)
return nil
default:
return fmt.Errorf("must be one of \"table\", \"tsv\", \"csv\", \"html\", or \"markdown\"")
}
}
func (e *formatEnum) Type() string { return "format" }
var (
listBinary bool
listSecret bool
listNoKeys bool
listNoValues bool
listTTL bool
listHeader bool
listFormat formatEnum = "table"
)
type columnKind int
const (
columnKey columnKind = iota
columnValue
columnTTL
) )
var listCmd = &cobra.Command{ var listCmd = &cobra.Command{
@ -59,9 +99,19 @@ func list(cmd *cobra.Command, args []string) error {
targetDB = "@" + dbName targetDB = "@" + dbName
} }
flags, err := enrichFlags() if listNoKeys && listNoValues && !listTTL {
if err != nil { return fmt.Errorf("cannot ls '%s': no columns selected; disable --no-keys/--no-values or pass --ttl", targetDB)
return fmt.Errorf("cannot ls '%s': %v", targetDB, err) }
var columns []columnKind
if !listNoKeys {
columns = append(columns, columnKey)
}
if !listNoValues {
columns = append(columns, columnValue)
}
if listTTL {
columns = append(columns, columnTTL)
} }
globPatterns, err := cmd.Flags().GetStringSlice("glob") globPatterns, err := cmd.Flags().GetStringSlice("glob")
@ -77,29 +127,19 @@ func list(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err) return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
} }
columnKinds, err := requireColumns(flags) showValues := !listNoValues
if err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
}
output := cmd.OutOrStdout() output := cmd.OutOrStdout()
tw := table.NewWriter() tw := table.NewWriter()
tw.SetOutputMirror(output) tw.SetOutputMirror(output)
tw.SetStyle(table.StyleDefault) tw.SetStyle(table.StyleDefault)
// Should these be settable flags?
tw.Style().Options.SeparateHeader = false tw.Style().Options.SeparateHeader = false
tw.Style().Options.SeparateFooter = false tw.Style().Options.SeparateFooter = false
tw.Style().Options.DrawBorder = false tw.Style().Options.DrawBorder = false
tw.Style().Options.SeparateRows = false tw.Style().Options.SeparateRows = false
tw.Style().Options.SeparateColumns = false tw.Style().Options.SeparateColumns = false
var maxContentWidths []int if listHeader {
maxContentWidths = make([]int, len(columnKinds)) tw.AppendHeader(headerRow(columns))
if flags.header {
header := buildHeaderCells(columnKinds)
updateMaxContentWidths(maxContentWidths, header)
tw.AppendHeader(stringSliceToRow(header))
} }
placeholder := "**********" placeholder := "**********"
@ -111,7 +151,7 @@ func list(cmd *cobra.Command, args []string) error {
transact: func(tx *badger.Txn, k []byte) error { transact: func(tx *badger.Txn, k []byte) error {
opts := badger.DefaultIteratorOptions opts := badger.DefaultIteratorOptions
opts.PrefetchSize = 10 opts.PrefetchSize = 10
opts.PrefetchValues = flags.value opts.PrefetchValues = showValues
it := tx.NewIterator(opts) it := tx.NewIterator(opts)
defer it.Close() defer it.Close()
var valueBuf []byte var valueBuf []byte
@ -126,33 +166,32 @@ func list(cmd *cobra.Command, args []string) error {
isSecret := meta&metaSecret != 0 isSecret := meta&metaSecret != 0
var valueStr string var valueStr string
if flags.value && (!isSecret || flags.secrets) { if showValues && (!isSecret || listSecret) {
if err := item.Value(func(v []byte) error { if err := item.Value(func(v []byte) error {
valueBuf = append(valueBuf[:0], v...) valueBuf = append(valueBuf[:0], v...)
return nil return nil
}); err != nil { }); err != nil {
return fmt.Errorf("cannot ls '%s': %v", targetDB, err) return fmt.Errorf("cannot ls '%s': %v", targetDB, err)
} }
valueStr = store.FormatBytes(flags.binary, valueBuf) valueStr = store.FormatBytes(listBinary, valueBuf)
} }
columns := make([]string, 0, len(columnKinds)) row := make(table.Row, 0, len(columns))
for _, column := range columnKinds { for _, col := range columns {
switch column { switch col {
case columnKey: case columnKey:
columns = append(columns, key) row = append(row, key)
case columnValue: case columnValue:
if isSecret && !flags.secrets { if isSecret && !listSecret {
columns = append(columns, placeholder) row = append(row, placeholder)
} else { } else {
columns = append(columns, valueStr) row = append(row, valueStr)
} }
case columnTTL: case columnTTL:
columns = append(columns, formatExpiry(item.ExpiresAt())) row = append(row, formatExpiry(item.ExpiresAt()))
} }
} }
updateMaxContentWidths(maxContentWidths, columns) tw.AppendRow(row)
tw.AppendRow(stringSliceToRow(columns))
} }
return nil return nil
}, },
@ -166,20 +205,117 @@ func list(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot ls '%s': No matches for pattern %s", targetDB, formatGlobPatterns(globPatterns)) return fmt.Errorf("cannot ls '%s': No matches for pattern %s", targetDB, formatGlobPatterns(globPatterns))
} }
applyColumnConstraints(tw, columnKinds, output, maxContentWidths) applyColumnWidths(tw, columns, output)
renderTable(tw)
flags.render(tw)
return nil return nil
} }
func headerRow(columns []columnKind) table.Row {
row := make(table.Row, 0, len(columns))
for _, col := range columns {
switch col {
case columnKey:
row = append(row, "Key")
case columnValue:
row = append(row, "Value")
case columnTTL:
row = append(row, "TTL")
}
}
return row
}
func applyColumnWidths(tw table.Writer, columns []columnKind, out io.Writer) {
termWidth := detectTerminalWidth(out)
if termWidth <= 0 {
return
}
tw.SetAllowedRowLength(termWidth)
// Padding per column: go-pretty's default is one space each side.
padding := len(columns) * 2
available := termWidth - padding
if available < len(columns) {
return
}
// Give key and TTL columns a fixed budget; value gets the rest.
const keyWidth = 30
const ttlWidth = 40
valueWidth := available
for _, col := range columns {
switch col {
case columnKey:
valueWidth -= keyWidth
case columnTTL:
valueWidth -= ttlWidth
}
}
if valueWidth < 10 {
valueWidth = 10
}
var configs []table.ColumnConfig
for i, col := range columns {
var maxW int
switch col {
case columnKey:
maxW = keyWidth
case columnValue:
maxW = valueWidth
case columnTTL:
maxW = ttlWidth
}
configs = append(configs, table.ColumnConfig{
Number: i + 1,
WidthMax: maxW,
WidthMaxEnforcer: text.WrapText,
})
}
tw.SetColumnConfigs(configs)
}
func detectTerminalWidth(out io.Writer) int {
type fd interface{ Fd() uintptr }
if f, ok := out.(fd); ok {
if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 {
return w
}
}
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
return w
}
if cols := os.Getenv("COLUMNS"); cols != "" {
if parsed, err := strconv.Atoi(cols); err == nil && parsed > 0 {
return parsed
}
}
return 0
}
func renderTable(tw table.Writer) {
switch listFormat.String() {
case "tsv":
tw.RenderTSV()
case "csv":
tw.RenderCSV()
case "html":
tw.RenderHTML()
case "markdown":
tw.RenderMarkdown()
default:
tw.Render()
}
}
func init() { func init() {
listCmd.Flags().BoolVarP(&binary, "binary", "b", false, "include binary data in text output") listCmd.Flags().BoolVarP(&listBinary, "binary", "b", false, "include binary data in text output")
listCmd.Flags().BoolVarP(&secret, "secret", "S", false, "display values marked as secret") listCmd.Flags().BoolVarP(&listSecret, "secret", "S", false, "display values marked as secret")
listCmd.Flags().BoolVar(&noKeys, "no-keys", false, "suppress the key column") listCmd.Flags().BoolVar(&listNoKeys, "no-keys", false, "suppress the key column")
listCmd.Flags().BoolVar(&noValues, "no-values", false, "suppress the value column") listCmd.Flags().BoolVar(&listNoValues, "no-values", false, "suppress the value column")
listCmd.Flags().BoolVarP(&ttl, "ttl", "t", false, "append a TTL column when entries expire") listCmd.Flags().BoolVarP(&listTTL, "ttl", "t", false, "append a TTL column when entries expire")
listCmd.Flags().BoolVar(&header, "header", false, "include header row") listCmd.Flags().BoolVar(&listHeader, "header", false, "include header row")
listCmd.Flags().VarP(&format, "format", "o", "output format (table|tsv|csv|markdown|html)") listCmd.Flags().VarP(&listFormat, "format", "o", "output format (table|tsv|csv|markdown|html)")
listCmd.Flags().StringSliceP("glob", "g", nil, "Filter keys with glob pattern (repeatable)") listCmd.Flags().StringSliceP("glob", "g", nil, "Filter keys with glob pattern (repeatable)")
listCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay())) listCmd.Flags().String("glob-sep", "", fmt.Sprintf("Characters treated as separators for globbing (default %q)", defaultGlobSeparatorsDisplay()))
rootCmd.AddCommand(listCmd) rootCmd.AddCommand(listCmd)

View file

@ -1,101 +0,0 @@
/*
Copyright © 2025 Lewis Wynne <lew@ily.rs>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package cmd
import (
"fmt"
"github.com/jedib0t/go-pretty/v6/table"
)
// ListArgs tracks the resolved flag configuration for the list command.
type ListArgs struct {
header bool
key bool
value bool
ttl bool
binary bool
secrets bool
render func(table.Writer)
}
// formatEnum implements pflag.Value for format selection.
type formatEnum string
func (e *formatEnum) String() string {
return string(*e)
}
func (e *formatEnum) Set(v string) error {
switch v {
case "table", "tsv", "csv", "html", "markdown":
*e = formatEnum(v)
return nil
default:
return fmt.Errorf("must be one of \"table\", \"tsv\", \"csv\", \"html\", or \"markdown\"")
}
}
func (e *formatEnum) Type() string {
return "format"
}
var (
binary bool = false
secret bool = false
noKeys bool = false
noValues bool = false
ttl bool = false
header bool = false
format formatEnum = "table"
)
func enrichFlags() (ListArgs, error) {
var renderFunc func(tw table.Writer)
switch format.String() {
case "tsv":
renderFunc = func(tw table.Writer) { tw.RenderTSV() }
case "csv":
renderFunc = func(tw table.Writer) { tw.RenderCSV() }
case "html":
renderFunc = func(tw table.Writer) { tw.RenderHTML() }
case "markdown":
renderFunc = func(tw table.Writer) { tw.RenderMarkdown() }
case "table":
renderFunc = func(tw table.Writer) { tw.Render() }
}
if noKeys && noValues && !ttl {
return ListArgs{}, fmt.Errorf("no columns selected; disable --no-keys/--no-values or pass --ttl")
}
return ListArgs{
header: header,
key: !noKeys,
value: !noValues,
ttl: ttl,
binary: binary,
render: renderFunc,
secrets: secret,
}, nil
}

View file

@ -1,270 +0,0 @@
/*
Copyright © 2025 Lewis Wynne <lew@ily.rs>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package cmd
import (
"fmt"
"io"
"os"
"slices"
"strconv"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"golang.org/x/term"
)
type columnKind int
const (
columnKey columnKind = iota
columnValue
columnTTL
)
func requireColumns(args ListArgs) ([]columnKind, error) {
var columns []columnKind
if args.key {
columns = append(columns, columnKey)
}
if args.value {
columns = append(columns, columnValue)
}
if args.ttl {
columns = append(columns, columnTTL)
}
if len(columns) == 0 {
return nil, fmt.Errorf("no columns selected; enable key, value, or ttl output")
}
return columns, nil
}
func buildHeaderCells(columnKinds []columnKind) []string {
labels := make([]string, 0, len(columnKinds))
for _, column := range columnKinds {
switch column {
case columnKey:
labels = append(labels, "Key")
case columnValue:
labels = append(labels, "Value")
case columnTTL:
labels = append(labels, "TTL")
}
}
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 updateMaxContentWidths(maxWidths []int, values []string) {
if len(maxWidths) == 0 {
return
}
limit := min(len(values), len(maxWidths))
for i := range limit {
width := text.LongestLineLen(values[i])
if width > maxWidths[i] {
maxWidths[i] = width
}
}
}
func applyColumnConstraints(tw table.Writer, columns []columnKind, out io.Writer, maxContentWidths []int) {
totalWidth := detectTerminalWidth(out)
if totalWidth <= 0 {
totalWidth = 100
}
contentWidth := contentWidthForStyle(totalWidth, tw, len(columns))
widths := distributeWidths(contentWidth, columns)
used := 0
for idx, width := range widths {
if width <= 0 {
width = 1
}
if idx < len(maxContentWidths) {
if actual := maxContentWidths[idx]; actual > 0 && width > actual {
width = actual
}
}
widths[idx] = width
used += width
}
remaining := contentWidth - used
for remaining > 0 {
progressed := false
for idx := range widths {
actual := 0
if idx < len(maxContentWidths) {
actual = maxContentWidths[idx]
}
if actual > 0 && widths[idx] >= actual {
continue
}
widths[idx]++
remaining--
progressed = true
if remaining == 0 {
break
}
}
if !progressed {
break
}
}
configs := make([]table.ColumnConfig, 0, len(columns))
for idx, width := range widths {
configs = append(configs, table.ColumnConfig{
Number: idx + 1,
WidthMax: width,
WidthMaxEnforcer: text.WrapText,
})
}
tw.SetColumnConfigs(configs)
tw.SetAllowedRowLength(totalWidth)
}
func contentWidthForStyle(totalWidth int, tw table.Writer, columnCount int) int {
if columnCount == 0 {
return totalWidth
}
style := tw.Style()
if style != nil {
totalWidth -= tableRowOverhead(style, columnCount)
}
if totalWidth < columnCount {
totalWidth = columnCount
}
return totalWidth
}
func tableRowOverhead(style *table.Style, columnCount int) int {
if style == nil || columnCount == 0 {
return 0
}
paddingWidth := text.StringWidthWithoutEscSequences(style.Box.PaddingLeft + style.Box.PaddingRight)
overhead := paddingWidth * columnCount
if style.Options.SeparateColumns && columnCount > 1 {
overhead += (columnCount - 1) * maxSeparatorWidth(style)
}
if style.Options.DrawBorder {
overhead += text.StringWidthWithoutEscSequences(style.Box.Left + style.Box.Right)
}
return overhead
}
func maxSeparatorWidth(style *table.Style) int {
widest := 0
separators := []string{
style.Box.MiddleSeparator,
style.Box.EmptySeparator,
style.Box.MiddleHorizontal,
style.Box.TopSeparator,
style.Box.BottomSeparator,
style.Box.MiddleVertical,
style.Box.LeftSeparator,
style.Box.RightSeparator,
}
for _, sep := range separators {
if width := text.StringWidthWithoutEscSequences(sep); width > widest {
widest = width
}
}
return widest
}
type fdWriter interface {
Fd() uintptr
}
func detectTerminalWidth(out io.Writer) int {
if f, ok := out.(fdWriter); ok {
if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 0 {
return w
}
}
if w, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && w > 0 {
return w
}
if cols := os.Getenv("COLUMNS"); cols != "" {
if parsed, err := strconv.Atoi(cols); err == nil && parsed > 0 {
return parsed
}
}
return 0
}
func distributeWidths(total int, columns []columnKind) []int {
if total <= 0 {
total = 100
}
hasTTL := slices.Contains(columns, columnTTL)
base := make([]float64, len(columns))
sum := 0.0
for i, c := range columns {
pct := basePercentageForColumn(c, hasTTL)
base[i] = pct
sum += pct
}
if sum == 0 {
sum = 1
}
widths := make([]int, len(columns))
remaining := total
const minColWidth = 10
for i := range columns {
width := max(int((base[i]/sum)*float64(total)), minColWidth)
widths[i] = width
remaining -= width
}
for i := 0; remaining > 0 && len(columns) > 0; i++ {
idx := i % len(columns)
widths[idx]++
remaining--
}
return widths
}
func basePercentageForColumn(c columnKind, hasTTL bool) float64 {
switch c {
case columnKey:
return 0.25
case columnValue:
if hasTTL {
return 0.5
}
return 0.75
case columnTTL:
return 0.25
default:
return 0.25
}
}