m/cli/metroctl: implement tabular print

Change-Id: I0511d48218bcc7e2e56af66839392bf11643733c
Reviewed-on: https://review.monogon.dev/c/monogon/+/1391
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/metropolis/cli/metroctl/BUILD.bazel b/metropolis/cli/metroctl/BUILD.bazel
index 7af98f3..004113e 100644
--- a/metropolis/cli/metroctl/BUILD.bazel
+++ b/metropolis/cli/metroctl/BUILD.bazel
@@ -1,4 +1,4 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
 
 go_library(
     name = "metroctl_lib",
@@ -13,6 +13,7 @@
         "node.go",
         "rpc.go",
         "set.go",
+        "table.go",
         "takeownership.go",
     ],
     data = [
@@ -45,3 +46,9 @@
     embed = [":metroctl_lib"],
     visibility = ["//visibility:public"],
 )
+
+go_test(
+    name = "metroctl_test",
+    srcs = ["table_test.go"],
+    embed = [":metroctl_lib"],
+)
diff --git a/metropolis/cli/metroctl/table.go b/metropolis/cli/metroctl/table.go
new file mode 100644
index 0000000..1d947d9
--- /dev/null
+++ b/metropolis/cli/metroctl/table.go
@@ -0,0 +1,128 @@
+package main
+
+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/metropolis/cli/metroctl/table_test.go b/metropolis/cli/metroctl/table_test.go
new file mode 100644
index 0000000..0ea1448
--- /dev/null
+++ b/metropolis/cli/metroctl/table_test.go
@@ -0,0 +1,45 @@
+package main
+
+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")
+	}
+}