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/metropolis/cli/metroctl/BUILD.bazel b/metropolis/cli/metroctl/BUILD.bazel
index 111c6e6..e3044d5 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", "go_test")
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
 load(":defs.bzl", "buildkind")
 
 buildkind(
@@ -28,7 +28,6 @@
         "cmd_takeownership.go",
         "main.go",
         "rpc.go",
-        "table.go",
         "table_node.go",
     ],
     data = select({
@@ -41,6 +40,7 @@
     importpath = "source.monogon.dev/metropolis/cli/metroctl",
     visibility = ["//visibility:private"],
     deps = [
+        "//go/clitable",
         "//metropolis/cli/metroctl/core",
         "//metropolis/cli/pkg/context",
         "//metropolis/cli/pkg/datafile",
@@ -64,9 +64,3 @@
     embed = [":metroctl_lib"],
     visibility = ["//visibility:public"],
 )
-
-go_test(
-    name = "metroctl_test",
-    srcs = ["table_test.go"],
-    embed = [":metroctl_lib"],
-)
diff --git a/metropolis/cli/metroctl/cmd_node.go b/metropolis/cli/metroctl/cmd_node.go
index b9692b6..1e4afa5 100644
--- a/metropolis/cli/metroctl/cmd_node.go
+++ b/metropolis/cli/metroctl/cmd_node.go
@@ -11,6 +11,7 @@
 
 	"github.com/spf13/cobra"
 
+	"source.monogon.dev/go/clitable"
 	"source.monogon.dev/metropolis/cli/metroctl/core"
 	clicontext "source.monogon.dev/metropolis/cli/pkg/context"
 	"source.monogon.dev/metropolis/node/core/identity"
@@ -173,7 +174,7 @@
 		}
 	}
 
-	var t table
+	var t clitable.Table
 	for _, n := range nodes {
 		// Filter the information we want client-side.
 		if len(qids) != 0 {
@@ -182,8 +183,8 @@
 				continue
 			}
 		}
-		t.add(nodeEntry(n))
+		t.Add(nodeEntry(n))
 	}
 
-	t.print(o, onlyColumns)
+	t.Print(o, onlyColumns)
 }
diff --git a/metropolis/cli/metroctl/table.go b/metropolis/cli/metroctl/table.go
deleted file mode 100644
index 1d947d9..0000000
--- a/metropolis/cli/metroctl/table.go
+++ /dev/null
@@ -1,128 +0,0 @@
-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_node.go b/metropolis/cli/metroctl/table_node.go
index c708443..07f994c 100644
--- a/metropolis/cli/metroctl/table_node.go
+++ b/metropolis/cli/metroctl/table_node.go
@@ -5,24 +5,25 @@
 	"sort"
 	"strings"
 
+	"source.monogon.dev/go/clitable"
 	"source.monogon.dev/metropolis/node/core/identity"
 	apb "source.monogon.dev/metropolis/proto/api"
 	cpb "source.monogon.dev/metropolis/proto/common"
 )
 
-func nodeEntry(n *apb.Node) entry {
-	res := entry{}
+func nodeEntry(n *apb.Node) clitable.Entry {
+	res := clitable.Entry{}
 
-	res.add("node id", identity.NodeID(n.Pubkey))
+	res.Add("node id", identity.NodeID(n.Pubkey))
 	state := n.State.String()
 	state = strings.ReplaceAll(state, "NODE_STATE_", "")
-	res.add("state", state)
+	res.Add("state", state)
 	address := "unknown"
 	if n.Status != nil && n.Status.ExternalAddress != "" {
 		address = n.Status.ExternalAddress
 	}
-	res.add("address", address)
-	res.add("health", n.Health.String())
+	res.Add("address", address)
+	res.Add("health", n.Health.String())
 
 	var roles []string
 	if n.Roles.ConsensusMember != nil {
@@ -35,7 +36,7 @@
 		roles = append(roles, "KubernetesWorker")
 	}
 	sort.Strings(roles)
-	res.add("roles", strings.Join(roles, ","))
+	res.Add("roles", strings.Join(roles, ","))
 
 	tpm := "unk"
 	switch n.TpmUsage {
@@ -46,10 +47,10 @@
 	case cpb.NodeTPMUsage_NODE_TPM_NOT_PRESENT:
 		tpm = "no"
 	}
-	res.add("tpm", tpm)
+	res.Add("tpm", tpm)
 
 	tshs := n.TimeSinceHeartbeat.GetSeconds()
-	res.add("heartbeat", fmt.Sprintf("%ds", tshs))
+	res.Add("heartbeat", fmt.Sprintf("%ds", tshs))
 
 	return res
 }
diff --git a/metropolis/cli/metroctl/table_test.go b/metropolis/cli/metroctl/table_test.go
deleted file mode 100644
index 0ea1448..0000000
--- a/metropolis/cli/metroctl/table_test.go
+++ /dev/null
@@ -1,45 +0,0 @@
-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")
-	}
-}