This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
// Package cliutil is the CLI's plumbing: a small subcommand tree over the
// standard flag package, and table/JSON rendering.
//
// It is deliberately not a CLI framework, but not for the reason usually
// given. Measured on this machine, net/http and crypto/tls alone put the floor
// for any Go HTTP client at 5.4 MB stripped; the pages binary is 6.0 MB, and
// the same program written with cobra came out at 5.9 MB. Half a megabyte
// either way is noise next to the TLS stack, so "cobra is too big" would be a
// claim the numbers do not support.
//
// The actual reason is dependency surface. This tool has a fixed set of about
// a dozen commands whose flags are plain strings, bools and ints; it needs no
// shell completion, no generated man pages, no dynamic command registration.
// The tree below is the part of a framework it would use, and it is small
// enough to read in one sitting — which matters more for a binary that CI jobs
// download and run with a deployment credential in the environment.
package cliutil
import (
"context"
"errors"
"flag"
"fmt"
"io"
"sort"
"strings"
"text/tabwriter"
)
// ErrUsage is returned when the command line does not name something runnable.
// The caller prints the usage text that accompanies it and exits non-zero.
var ErrUsage = errors.New("usage")
// Command is one node of the command tree. A node either runs (Exec) or has
// children (Sub), never both.
type Command struct {
// Name is the single word that selects this command.
Name string
// Args describes the positional arguments for the usage line, e.g. "<name>".
Args string
// Short is the one-line summary listed by the parent.
Short string
// Long is optional additional prose printed by --help.
Long string
// Flags registers this command's flags. It runs once per invocation, before
// parsing, so the variables it binds are the ones Exec reads.
Flags func(fs *flag.FlagSet)
// Exec runs the command with the positional arguments left after parsing.
Exec func(ctx context.Context, args []string) error
// Sub are the child commands, if any.
Sub []*Command
// parent is filled in during dispatch so usage text can print the full path.
parent *Command
}
// Persistent registers flags that every command in the tree accepts.
//
// The values it binds must already hold whatever an outer parse produced, and
// it must register them with those values as the defaults — see Globals.Register
// in internal/clicmd. Registering with a fixed default instead would reset a
// flag given before the subcommand name ("pages --server X project list"),
// because flag.StringVar assigns the default at registration time.
type Persistent func(fs *flag.FlagSet)
// Run parses args against c and dispatches. out receives usage and help text.
func Run(ctx context.Context, c *Command, args []string, out io.Writer, persistent Persistent) error {
fs := flag.NewFlagSet(c.path(), flag.ContinueOnError)
fs.SetOutput(out)
fs.Usage = func() { c.printUsage(out, fs) }
if persistent != nil {
persistent(fs)
}
if c.Flags != nil {
c.Flags(fs)
}
if len(c.Sub) == 0 {
args = permute(fs, args)
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
// flag has already printed the usage via fs.Usage.
return nil
}
// flag printed the message; adding our own would double it.
return ErrUsage
}
rest := fs.Args()
if len(c.Sub) > 0 && len(rest) > 0 {
for _, sub := range c.Sub {
if sub.Name == rest[0] {
sub.parent = c
return Run(ctx, sub, rest[1:], out, persistent)
}
}
fmt.Fprintf(out, "unknown command %q\n\n", rest[0])
c.printUsage(out, fs)
return ErrUsage
}
// A leaf, or a parent invoked without naming a child. The latter still gets
// its Exec so the root can answer --version before falling back to usage.
if c.Exec == nil {
c.printUsage(out, fs)
return ErrUsage
}
err := c.Exec(ctx, rest)
if errors.Is(err, ErrUsage) {
c.printUsage(out, fs)
}
return err
}
// permute moves flags ahead of positional arguments, so that
//
// pages project create demo --spa
//
// works as well as the order the flag package wants:
//
// pages project create --spa demo
//
// Stopping at the first non-flag argument is right for a command that has
// children — the first positional there names the child, and its flags belong
// to it, not to us. A leaf has no such ambiguity, so it gets the permuting
// parse people expect from every other CLI they use.
//
// Two tokens are left exactly where they are: "--" terminates flags for good,
// and an unrecognised flag is passed through alone so that flag.Parse reports
// it rather than this function silently swallowing the argument after it.
func permute(fs *flag.FlagSet, args []string) []string {
known := map[string]*flag.Flag{}
fs.VisitAll(func(f *flag.Flag) { known[f.Name] = f })
var flags, rest []string
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
rest = append(rest, args[i+1:]...)
break
}
// "-" alone is the conventional name for stdin, not a flag.
if len(a) < 2 || a[0] != '-' {
rest = append(rest, a)
continue
}
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if strings.ContainsRune(name, '=') {
continue // --name=value carries its own argument
}
// A non-boolean flag takes the next token with it. Booleans must not
// swallow anything: "--spa demo" is a flag and a positional.
if f, ok := known[name]; ok && !isBoolFlag(f) && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
}
// The separator makes the tail positional even if a value there looks like
// a flag, which matters for paths and metadata values.
return append(flags, append([]string{"--"}, rest...)...)
}
func isBoolFlag(f *flag.Flag) bool {
b, ok := f.Value.(interface{ IsBoolFlag() bool })
return ok && b.IsBoolFlag()
}
// UsageErrorf returns an error that makes Run print the command's usage after
// the caller reports the message. Use it for "wrong number of arguments" and
// friends, where the fix is visible in the usage text.
func UsageErrorf(format string, args ...any) error {
return &usageError{msg: fmt.Sprintf(format, args...)}
}
type usageError struct{ msg string }
func (e *usageError) Error() string { return e.msg }
// Is makes errors.Is(err, ErrUsage) true for any usageError.
func (e *usageError) Is(target error) bool { return target == ErrUsage }
// path is the space-separated command path, used in usage text and as the
// FlagSet name so flag's own error messages name the right command.
func (c *Command) path() string {
if c.parent == nil {
return c.Name
}
return c.parent.path() + " " + c.Name
}
func (c *Command) printUsage(out io.Writer, fs *flag.FlagSet) {
if c.Short != "" {
fmt.Fprintf(out, "%s — %s\n\n", c.path(), c.Short)
}
fmt.Fprintf(out, "Usage:\n %s", c.path())
if len(c.Sub) > 0 {
fmt.Fprint(out, " <command>")
}
if hasFlags(fs) {
fmt.Fprint(out, " [flags]")
}
if c.Args != "" {
fmt.Fprintf(out, " %s", c.Args)
}
fmt.Fprint(out, "\n")
if len(c.Sub) > 0 {
fmt.Fprint(out, "\nCommands:\n")
tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0)
subs := append([]*Command(nil), c.Sub...)
sort.Slice(subs, func(i, j int) bool { return subs[i].Name < subs[j].Name })
for _, sub := range subs {
fmt.Fprintf(tw, " %s\t%s\n", sub.Name, sub.Short)
}
tw.Flush()
}
if hasFlags(fs) {
fmt.Fprint(out, "\nFlags:\n")
fs.PrintDefaults()
}
if c.Long != "" {
fmt.Fprintf(out, "\n%s\n", strings.TrimSpace(c.Long))
}
}
func hasFlags(fs *flag.FlagSet) bool {
n := 0
fs.VisitAll(func(*flag.Flag) { n++ })
return n > 0
}
+197
View File
@@ -0,0 +1,197 @@
package cliutil
import (
"context"
"errors"
"flag"
"io"
"reflect"
"strings"
"testing"
)
// optBool is the minimal stand-in for clicmd's optional flags: a flag.Value
// that announces it takes no argument.
type optBool struct{ v bool }
func (o *optBool) String() string { return "" }
func (o *optBool) Set(string) error { o.v = true; return nil }
func (o *optBool) IsBoolFlag() bool { return true }
func TestPermute(t *testing.T) {
newFS := func() *flag.FlagSet {
fs := flag.NewFlagSet("test", flag.ContinueOnError)
fs.String("name", "", "")
fs.Bool("yes", false, "")
fs.Var(&optBool{}, "spa", "")
return fs
}
cases := []struct {
desc string
in []string
want []string
}{
{
desc: "flags after the positional, which is what people type",
in: []string{"demo", "--name", "Demo", "--spa"},
want: []string{"--name", "Demo", "--spa", "--", "demo"},
},
{
desc: "already in flag package order, unchanged apart from the separator",
in: []string{"--name", "Demo", "demo"},
want: []string{"--name", "Demo", "--", "demo"},
},
{
desc: "a bool flag must not swallow the positional that follows it",
in: []string{"--yes", "demo"},
want: []string{"--yes", "--", "demo"},
},
{
desc: "single-dash spelling is the same flag",
in: []string{"demo", "-name", "Demo"},
want: []string{"-name", "Demo", "--", "demo"},
},
{
desc: "--flag=value carries its own argument",
in: []string{"demo", "--name=Demo", "other"},
want: []string{"--name=Demo", "--", "demo", "other"},
},
{
desc: "a value that looks like a flag is still the flag's value",
in: []string{"demo", "--name", "-weird"},
want: []string{"--name", "-weird", "--", "demo"},
},
{
desc: "everything after -- stays positional",
in: []string{"--yes", "--", "--name", "demo"},
want: []string{"--yes", "--", "--name", "demo"},
},
{
desc: "a lone dash is a positional, not a flag",
in: []string{"-"},
want: []string{"--", "-"},
},
{
desc: "an unknown flag is passed through alone so flag.Parse reports it",
in: []string{"--nope", "demo"},
want: []string{"--nope", "--", "demo"},
},
}
for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
got := permute(newFS(), tc.in)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("permute(%q)\n got %q\nwant %q", tc.in, got, tc.want)
}
})
}
}
// TestPermutedFlagsReachExec is the property the permutation exists for: the
// command sees the same flags and the same positionals either way round.
func TestPermutedFlagsReachExec(t *testing.T) {
for _, args := range [][]string{
{"create", "demo", "--name", "Demo", "--spa"},
{"create", "--name", "Demo", "--spa", "demo"},
{"create", "--name", "Demo", "demo", "--spa"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var (
name string
spa optBool
rest []string
)
root := &Command{
Name: "test",
Sub: []*Command{{
Name: "create",
Flags: func(fs *flag.FlagSet) {
fs.StringVar(&name, "name", "", "")
fs.Var(&spa, "spa", "")
},
Exec: func(ctx context.Context, args []string) error {
rest = args
return nil
},
}},
}
if err := Run(context.Background(), root, args, io.Discard, nil); err != nil {
t.Fatalf("Run: %v", err)
}
if name != "Demo" || !spa.v {
t.Errorf("flags: name=%q spa=%v, want Demo/true", name, spa.v)
}
if !reflect.DeepEqual(rest, []string{"demo"}) {
t.Errorf("positionals: %q, want [demo]", rest)
}
})
}
}
// TestParentDoesNotPermute guards the other half of the rule: a parent must
// leave a child's flags alone, or "pages project create --spa" would try to
// parse --spa against the project command and fail.
func TestParentDoesNotPermute(t *testing.T) {
var spa bool
root := &Command{
Name: "test",
Sub: []*Command{{
Name: "project",
Sub: []*Command{{
Name: "create",
Flags: func(fs *flag.FlagSet) { fs.BoolVar(&spa, "spa", false, "") },
Exec: func(context.Context, []string) error { return nil },
}},
}},
}
if err := Run(context.Background(), root, []string{"project", "create", "--spa"}, io.Discard, nil); err != nil {
t.Fatalf("Run: %v", err)
}
if !spa {
t.Error("--spa did not reach the leaf command")
}
}
func TestUsageErrors(t *testing.T) {
root := &Command{
Name: "test",
Sub: []*Command{{
Name: "leaf",
Exec: func(context.Context, []string) error { return UsageErrorf("expected %d args", 1) },
}},
}
t.Run("unknown command", func(t *testing.T) {
var out strings.Builder
err := Run(context.Background(), root, []string{"nope"}, &out, nil)
if !errors.Is(err, ErrUsage) {
t.Fatalf("err = %v, want ErrUsage", err)
}
if !strings.Contains(out.String(), `unknown command "nope"`) {
t.Errorf("output did not name the unknown command:\n%s", out.String())
}
})
t.Run("a parent with no child named is usage, not a crash", func(t *testing.T) {
err := Run(context.Background(), root, nil, io.Discard, nil)
if !errors.Is(err, ErrUsage) {
t.Fatalf("err = %v, want ErrUsage", err)
}
})
t.Run("UsageErrorf prints the command's usage and keeps its message", func(t *testing.T) {
var out strings.Builder
err := Run(context.Background(), root, []string{"leaf"}, &out, nil)
if !errors.Is(err, ErrUsage) {
t.Fatalf("err = %v, want ErrUsage", err)
}
if err.Error() != "expected 1 args" {
t.Errorf("message = %q", err.Error())
}
if !strings.Contains(out.String(), "Usage:\n test leaf") {
t.Errorf("usage text missing or misnamed:\n%s", out.String())
}
})
}
+131
View File
@@ -0,0 +1,131 @@
package cliutil
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
)
// ParseDuration accepts Go's duration syntax plus the day, week and year
// suffixes an operator actually types for a key lifetime ("90d", "1y").
//
// A year is 365 days and a day is 24 hours: no calendar arithmetic, no time
// zones, no leap seconds. The value becomes an absolute expiry timestamp on the
// client precisely so a difference of a few hours cannot matter.
func ParseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, errors.New("empty duration")
}
var mult time.Duration
switch s[len(s)-1] {
case 'd':
mult = 24 * time.Hour
case 'w':
mult = 7 * 24 * time.Hour
case 'y':
mult = 365 * 24 * time.Hour
default:
d, err := time.ParseDuration(s)
if err != nil {
return 0, fmt.Errorf("invalid duration %q: use 30s, 5m, 12h, 90d, 2w or 1y", s)
}
return d, nil
}
n, err := strconv.ParseFloat(s[:len(s)-1], 64)
if err != nil {
return 0, fmt.Errorf("invalid duration %q: use 30s, 5m, 12h, 90d, 2w or 1y", s)
}
return time.Duration(n * float64(mult)), nil
}
// ParseBytes accepts a plain byte count or one with a unit suffix: "1048576",
// "1MiB", "256M", "1.5G".
//
// K, M, G and T all mean the binary multiple, whether or not the suffix is
// spelled with an "i". Disk-quota flags are set to round binary numbers, and a
// tool that quietly read "256MB" as 256,000,000 would produce a limit its user
// did not ask for.
func ParseBytes(s string) (int64, error) {
t := strings.TrimSpace(s)
if t == "" {
return 0, errors.New("empty size")
}
digits := strings.TrimRight(t, "bBiIkKmMgGtT")
// Uppercase before trimming so "MiB", "MIB" and "mib" all reduce to "M".
unit := strings.ToUpper(t[len(digits):])
unit = strings.TrimSuffix(unit, "B")
unit = strings.TrimSuffix(unit, "I")
n, err := strconv.ParseFloat(strings.TrimSpace(digits), 64)
if err != nil || n < 0 {
return 0, fmt.Errorf("invalid size %q: use a byte count or 256MiB, 2GiB", s)
}
var mult float64 = 1
switch unit {
case "":
case "K":
mult = 1 << 10
case "M":
mult = 1 << 20
case "G":
mult = 1 << 30
case "T":
mult = 1 << 40
default:
return 0, fmt.Errorf("invalid size %q: unknown unit %q", s, unit)
}
return int64(n * mult), nil
}
// KeyValue splits a "k=v" argument. The value may contain further '=' signs.
func KeyValue(s string) (key, value string, err error) {
k, v, ok := strings.Cut(s, "=")
if !ok || k == "" {
return "", "", fmt.Errorf("expected key=value, got %q", s)
}
return k, v, nil
}
// ErrAborted is returned when the user declines a confirmation prompt.
var ErrAborted = errors.New("aborted")
// Confirm asks for a yes before a destructive operation.
//
// It refuses rather than prompts when stdin is not a terminal: a pipeline that
// blocks forever on a prompt nobody can see is worse than one that fails and
// tells the operator to pass --yes.
func Confirm(in io.Reader, out io.Writer, prompt string) error {
if f, ok := in.(*os.File); ok && !isTerminal(f) {
return fmt.Errorf("%w: refusing to prompt for confirmation with no terminal; pass --yes", ErrAborted)
}
fmt.Fprintf(out, "%s [y/N]: ", prompt)
line, err := bufio.NewReader(in).ReadString('\n')
if err != nil && line == "" {
return ErrAborted
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return nil
}
return ErrAborted
}
// isTerminal reports whether f is a character device. This is the cheap
// stdlib-only approximation of a tty check; it is used to decide whether to
// prompt and whether to draw progress, never for anything security-relevant.
func isTerminal(f *os.File) bool {
fi, err := f.Stat()
if err != nil {
return false
}
return fi.Mode()&os.ModeCharDevice != 0
}
// IsTerminal reports whether f is attached to a terminal.
func IsTerminal(f *os.File) bool { return isTerminal(f) }
+169
View File
@@ -0,0 +1,169 @@
package cliutil
import (
"strconv"
"time"
)
// The Opt* types are flag.Value implementations that remember whether they were
// given on the command line.
//
// They exist for PATCH: api.ProjectPatch uses a pointer per field so the server
// can tell "leave this alone" from "set it to the zero value". A plain
// fs.StringVar cannot express that difference — an unset --not-found-file and
// an explicit --not-found-file="" both arrive as "". Each type's Ptr method
// produces exactly the pointer the patch field wants.
// OptString is an optional string flag.
type OptString struct {
Val string
Present bool
}
func (o *OptString) String() string {
if o == nil {
return ""
}
return o.Val
}
func (o *OptString) Set(s string) error {
o.Val, o.Present = s, true
return nil
}
// Ptr returns nil unless the flag was given.
func (o *OptString) Ptr() *string {
if !o.Present {
return nil
}
return &o.Val
}
// OptBool is an optional boolean flag. It may be written as --flag as well as
// --flag=false.
type OptBool struct {
Val bool
Present bool
}
func (o *OptBool) String() string {
if o == nil {
return "false"
}
return strconv.FormatBool(o.Val)
}
func (o *OptBool) Set(s string) error {
v, err := strconv.ParseBool(s)
if err != nil {
return err
}
o.Val, o.Present = v, true
return nil
}
// IsBoolFlag lets flag accept "--spa" without an explicit value.
func (o *OptBool) IsBoolFlag() bool { return true }
// Ptr returns nil unless the flag was given.
func (o *OptBool) Ptr() *bool {
if !o.Present {
return nil
}
return &o.Val
}
// OptInt is an optional integer flag.
type OptInt struct {
Val int
Present bool
}
func (o *OptInt) String() string {
if o == nil {
return ""
}
return strconv.Itoa(o.Val)
}
func (o *OptInt) Set(s string) error {
v, err := strconv.Atoi(s)
if err != nil {
return err
}
o.Val, o.Present = v, true
return nil
}
// Ptr returns nil unless the flag was given.
func (o *OptInt) Ptr() *int {
if !o.Present {
return nil
}
return &o.Val
}
// OptBytes is an optional byte count, written as a plain number or with a unit
// suffix ("256MiB").
type OptBytes struct {
Val int64
Present bool
}
func (o *OptBytes) String() string {
if o == nil {
return ""
}
return strconv.FormatInt(o.Val, 10)
}
func (o *OptBytes) Set(s string) error {
v, err := ParseBytes(s)
if err != nil {
return err
}
o.Val, o.Present = v, true
return nil
}
// Ptr returns nil unless the flag was given.
func (o *OptBytes) Ptr() *int64 {
if !o.Present {
return nil
}
return &o.Val
}
// OptDuration is an optional duration, accepting the same suffixes as
// ParseDuration.
type OptDuration struct {
Val time.Duration
Present bool
}
func (o *OptDuration) String() string {
if o == nil {
return ""
}
return o.Val.String()
}
func (o *OptDuration) Set(s string) error {
v, err := ParseDuration(s)
if err != nil {
return err
}
o.Val, o.Present = v, true
return nil
}
// SecondsPtr returns the duration in whole seconds, or nil if the flag was not
// given. The API expresses grace periods as an integer number of seconds.
func (o *OptDuration) SecondsPtr() *int {
if !o.Present {
return nil
}
s := int(o.Val / time.Second)
return &s
}
+159
View File
@@ -0,0 +1,159 @@
package cliutil
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"text/tabwriter"
"time"
)
// Output formats.
const (
FormatTable = "table"
FormatJSON = "json"
)
// ValidFormat reports whether s names an output format.
func ValidFormat(s string) bool { return s == FormatTable || s == FormatJSON }
// Printer writes command output in the format the user asked for.
type Printer struct {
Out io.Writer
Format string
}
// Print renders v. In table format it calls table to build the rendering; a nil
// table means the value has no tabular form and JSON is used regardless.
func (p *Printer) Print(v any, table func() *Table) error {
if p.Format == FormatJSON || table == nil {
return p.JSON(v)
}
return table().Write(p.Out)
}
// JSON writes v as indented JSON with a trailing newline.
func (p *Printer) JSON(v any) error {
enc := json.NewEncoder(p.Out)
enc.SetIndent("", " ")
// The output is read by humans and by jq, neither of which wants &, < and >
// spelled as & and friends.
enc.SetEscapeHTML(false)
return enc.Encode(v)
}
// Printf writes a human-readable line, and nothing at all in JSON format —
// progress chatter must never end up in a stream something is parsing.
func (p *Printer) Printf(format string, args ...any) {
if p.Format == FormatJSON {
return
}
fmt.Fprintf(p.Out, format, args...)
}
// Table is a column-aligned rendering built up row by row.
type Table struct {
header []string
rows [][]string
}
// NewTable starts a table with the given column headings.
func NewTable(header ...string) *Table { return &Table{header: header} }
// Row appends a row. Cells beyond the header count are kept: a ragged table is
// a formatting annoyance, not a reason to drop data.
func (t *Table) Row(cells ...string) { t.rows = append(t.rows, cells) }
// Len reports how many rows have been added.
func (t *Table) Len() int { return len(t.rows) }
// Write renders the table. An empty table prints its header only, so a caller
// can tell "no rows" from "the command did nothing".
func (t *Table) Write(w io.Writer) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if len(t.header) > 0 {
fmt.Fprintln(tw, strings.Join(t.header, "\t"))
}
for _, row := range t.rows {
fmt.Fprintln(tw, strings.Join(row, "\t"))
}
return tw.Flush()
}
// ------------------------------------------------------------ cell helpers
// Dash is what an empty cell shows, so a missing value is visibly missing
// rather than looking like a column-alignment mistake.
const Dash = "-"
// Str renders a string cell, showing Dash when empty.
func Str(s string) string {
if s == "" {
return Dash
}
return s
}
// Bool renders a boolean cell.
func Bool(b bool) string {
if b {
return "yes"
}
return "no"
}
// Plural renders a count with its noun, adding "s" for anything but one.
// Only regular nouns are pluralized correctly, which is all this CLI has.
func Plural(n int, noun string) string {
if n == 1 {
return "1 " + noun
}
return strconv.Itoa(n) + " " + noun + "s"
}
// Time renders an absolute local timestamp to the second. Absolute rather than
// relative: these values go into CI logs that are read days later, where "2
// hours ago" has lost its reference point.
func Time(t time.Time) string {
if t.IsZero() {
return Dash
}
return t.Local().Format("2006-01-02 15:04:05")
}
// TimePtr renders an optional timestamp.
func TimePtr(t *time.Time) string {
if t == nil {
return Dash
}
return Time(*t)
}
// Bytes renders a byte count in binary units, as a human reads it.
func Bytes(n int64) string {
const unit = 1024
if n < unit {
return strconv.FormatInt(n, 10) + " B"
}
div, exp := int64(unit), 0
for n/div >= unit && exp < 4 {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTP"[exp])
}
// Truncate shortens s to at most max runes, marking the cut with an ellipsis so
// a clipped value is never mistaken for a complete one.
func Truncate(s string, max int) string {
if max <= 1 {
return s
}
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max-1]) + "…"
}