go/clitable: factor out from metroctl

We need the same functionality in bmcli, so factor it out from metroctl
into a generic library.

Change-Id: I3fb3dfaae44a64d204e9220f117f379c382c5c4f
Reviewed-on: https://review.monogon.dev/c/monogon/+/2172
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/go/clitable/BUILD.bazel b/go/clitable/BUILD.bazel
new file mode 100644
index 0000000..539328d
--- /dev/null
+++ b/go/clitable/BUILD.bazel
@@ -0,0 +1,14 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "clitable",
+    srcs = ["table.go"],
+    importpath = "source.monogon.dev/go/clitable",
+    visibility = ["//visibility:public"],
+)
+
+go_test(
+    name = "clitable_test",
+    srcs = ["table_test.go"],
+    embed = [":clitable"],
+)
diff --git a/go/clitable/table.go b/go/clitable/table.go
new file mode 100644
index 0000000..ce1f7fa
--- /dev/null
+++ b/go/clitable/table.go
@@ -0,0 +1,140 @@
+// Package clitable implements tabular display for command line tools.
+//
+// The generated tables are vaguely reminiscent of the output of 'kubectl'. For
+// example:
+//
+//	NAME ADDRESS  STATUS
+//	foo  1.2.3.4  Healthy
+//	bar  1.2.3.12 Timeout
+//
+// The tables are sparse by design, with each Entry having a number of Key/Value
+// entries. Then, all keys for all entries get unified into columns, and entries
+// without a given key are rendered spare (i.e. the rendered cell is empty).
+package clitable
+
+import (
+	"fmt"
+	"io"
+	"strings"
+)
+
+// Table is a list of entries that form a table with sparse columns. Each entry
+// defines its own columns.
+type Table struct {
+	entries []Entry
+}
+
+// Add an entry to the table.
+func (t *Table) Add(e Entry) {
+	t.entries = append(t.entries, e)
+}
+
+// An Entry is made up of column key -> value pairs.
+type Entry struct {
+	columns []entryColumn
+}
+
+// Add a key/value pair to the entry.
+func (e *Entry) Add(key, value string) {
+	e.columns = append(e.columns, entryColumn{
+		key:   key,
+		value: value,
+	})
+}
+
+// Get a value from a given key, returning zero string if not set.
+func (e *Entry) Get(key string) string {
+	for _, col := range e.columns {
+		if col.key == key {
+			return col.value
+		}
+	}
+	return ""
+}
+
+// An entryColumn is a pair for table column key and entry column value.
+type entryColumn struct {
+	key   string
+	value string
+}
+
+// Columns returns the keys and widths of columns that are present in the table's
+// entries.
+func (t *Table) Columns() Columns {
+	var res Columns
+	for _, e := range t.entries {
+		for _, c := range e.columns {
+			tc := res.upsert(c.key)
+			if len(c.value) > tc.width {
+				tc.width = len(c.value)
+			}
+		}
+	}
+	return res
+}
+
+type Columns []*Column
+
+// A Column in a table, not containing all entries that make up this table, but
+// containing their maximum width (for layout purposes).
+type Column struct {
+	// key is the column key.
+	key string
+	// width is the maximum width (in runes) of all the entries' data in this column.
+	width int
+}
+
+// upsert a key into a list of columns, returning the upserted column.
+func (c *Columns) upsert(key string) *Column {
+	for _, col := range *c {
+		if col.key == key {
+			return col
+		}
+	}
+	col := &Column{
+		key:   key,
+		width: len(key),
+	}
+	*c = append(*c, col)
+	return col
+}
+
+// filter returns a copy of columns where the only columns present are the ones
+// whose onlyColumns values are true. If only columns is nil, no filtering takes
+// place (all columns are returned).
+func (c Columns) filter(onlyColumns map[string]bool) Columns {
+	var res []*Column
+	for _, cc := range c {
+		if onlyColumns != nil && !onlyColumns[cc.key] {
+			continue
+		}
+		res = append(res, cc)
+	}
+	return res
+}
+
+// PrintHeader writes a table-like header to the given file, keeping margin
+// spaces between columns.
+func (c Columns) printHeader(f io.Writer, margin int) {
+	for _, cc := range c {
+		fmt.Fprintf(f, "%-*s", cc.width+margin, strings.ToUpper(cc.key))
+	}
+	fmt.Fprintf(f, "\n")
+}
+
+// Print writes a table-like representation of this table to the given file,
+// first filtering the columns by onlyColumns (if not set, no filtering takes
+// place).
+func (t *Table) Print(f io.Writer, onlyColumns map[string]bool) {
+	margin := 3
+	cols := t.Columns().filter(onlyColumns)
+	cols.printHeader(f, margin)
+
+	for _, e := range t.entries {
+		for _, c := range cols {
+			v := e.Get(c.key)
+			fmt.Fprintf(f, "%-*s", c.width+margin, v)
+		}
+		fmt.Fprintf(f, "\n")
+	}
+}
diff --git a/go/clitable/table_test.go b/go/clitable/table_test.go
new file mode 100644
index 0000000..5fca84c
--- /dev/null
+++ b/go/clitable/table_test.go
@@ -0,0 +1,45 @@
+package clitable
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+)
+
+// TestTableLayout performs a smoke test of the table layout functionality.
+func TestTableLayout(t *testing.T) {
+	tab := Table{}
+
+	e := Entry{}
+	e.Add("id", "short")
+	e.Add("labels", "")
+	tab.Add(e)
+
+	e = Entry{}
+	e.Add("whoops", "only in second")
+	e.Add("labels", "bar")
+	e.Add("id", "this one is a very long one")
+	tab.Add(e)
+
+	e = Entry{}
+	e.Add("id", "normal length")
+	e.Add("labels", "foo")
+	tab.Add(e)
+
+	buf := bytes.NewBuffer(nil)
+	tab.Print(buf, nil)
+
+	golden := `
+ID                            LABELS   WHOOPS           
+short                                                   
+this one is a very long one   bar      only in second   
+normal length                 foo                       
+`
+	golden = strings.TrimSpace(golden)
+	got := strings.TrimSpace(buf.String())
+	if got != golden {
+		t.Logf("wanted: \n" + golden)
+		t.Logf("got: \n" + got)
+		t.Errorf("mismatch")
+	}
+}