output

package
v0.1.0-rc.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 11, 2026 License: MIT Imports: 10 Imported by: 0

Documentation

Overview

Package output is the single channel for user-facing output.

Everything a user is meant to read goes through a Writer — never fmt.Println. Two implementations back the --output flag: PrettyWriter renders lipgloss-styled text for a human, JSONWriter renders NDJSON for a machine. Both are constructed with explicit streams so a test can capture them.

slog is not this

log/slog is a debug-only diagnostic channel on stderr, silent on a normal run and never used for reporting. See SetupLogger. The boundary is settled here so that no later package has to think about it: if a user is supposed to see it, it is a Writer call; if it only helps someone debugging, it is slog.

Writes are best-effort

Writer has no error channel, and reporting a failed write would itself need a working stream. Output calls therefore discard their error, which is why .golangci.yml excludes fmt.Fprintln and lipgloss.Fprintln from errcheck.

Index

Constants

View Source
const LevelSilent = slog.LevelError + 1

LevelSilent is above every level slog defines, so a logger set to it emits nothing at all.

It is the default, and that is the whole point of the boundary this package draws: slog is a diagnostic channel for someone debugging labelsync, not a reporting channel for someone using it. A normal run produces no slog records, and anything a user is meant to read went through a Writer instead. Without a silent default, warn- and error-level diagnostics would leak onto stderr and interleave with the real output.

Variables

This section is empty.

Functions

func IsTTY

func IsTTY(stream any) bool

IsTTY reports whether stream is an interactive terminal. It takes any stream — os.Stdin as readily as os.Stderr — because the two decisions below ask about different ones.

Styling does not need this. NewPrettyWriter wraps each stream in a colorprofile.Writer, which makes the same determination itself and strips the escape sequences when the answer is no. This is for the decisions that are not about colour:

  • the rate-limit countdown, which rewrites one line with \r on a terminal and logs at a fixed interval into a pipe, because control characters in a CI log are unreadable. It asks about stderr, where it draws;
  • the prune prompt, which must never be shown to a pipe — a CI job blocked on an invisible prompt hangs until it is cancelled. It asks about stdin, because the hang it prevents is a read with nobody to answer it. A job with a terminal on stderr and its stdin closed must still refuse to prompt.

Anything without a file descriptor — a bytes.Buffer in a test, say — is not a terminal.

func RenderColumns

func RenderColumns(rows [][]string) string

RenderColumns aligns rows into columns and returns them as lines, with no header, no border, and no trailing whitespace.

This is the renderer behind the pretty diff. The diff is a list, not a table — there is nothing to put in a header row and a box around it would fight the per-repository grouping — but the action, name, and colour columns still have to line up down the page or the changes are unreadable:

  • create type: bug #d73a4a "Something isn't working" ~ recolour wontfix #d73a4a → #16a3c4 = ok priority: high

Callers supply the gutter (the +/~/=) as the first cell. Short rows are fine: a row with fewer cells than the widest simply ends early.

func RenderTable

func RenderTable(headers []string, rows [][]string) string

RenderTable renders headers and rows as a bordered, column-aligned table. Column widths are computed from the content, headers included.

This is what PrettyWriter.WriteTable uses — list output such as `groups` or `cache info`, where a header row is meaningful. For the diff, see RenderColumns.

func SetupDefaultLogger

func SetupDefaultLogger(format Format, debug bool) *slog.LevelVar

SetupDefaultLogger installs the default slog logger on os.Stderr.

func SetupLogger

func SetupLogger(stderr io.Writer, format Format, debug bool) *slog.LevelVar

SetupLogger installs the process-wide default slog logger on stderr and returns the slog.LevelVar gating it.

debug selects slog.LevelDebug; otherwise the level is LevelSilent and nothing is written. Records are formatted to match the output format, so `--debug --output=json` yields a stderr stream that is JSON all the way down.

Cobra parses persistent flags after the root command is built, so the level is returned rather than baked in: call SetupLogger once during construction, then level.Set(slog.LevelDebug) from PersistentPreRunE once --debug is known.

Stderr, never stdout: `labelsync groups --output=json | jq` must not have debug lines spliced into the object stream.

func Table

func Table[T any](w Writer, rows []T, cols ...Column[T])

Table writes rows as the command's product: a bordered table for a human, one JSON object per row for a machine.

The two renderings come from different places, which is the point. The table comes from cols. The JSON comes from marshalling each row directly, so its keys and types are the struct's own:

type GroupRow struct {
    Name         string `json:"group"`
    Repositories int    `json:"repositories"`
}

gives {"group":"websites","repositories":12} — a number, not "12", so a consumer can filter on it. Those json tags are a public contract in the same way error_kind is: they may be added to, not renamed.

Passing something other than a tagged struct is legal and rarely what you want: a plain string row marshals to a bare JSON string, which is a valid NDJSON line that no field selector can read.

Types

type Column

type Column[T any] struct {
	// Header is the column heading. Prose: it may be reworded freely, because no
	// machine reads it.
	Header string

	// Cell renders one row in this column.
	Cell func(T) string
}

Column describes one column of a pretty table: the heading a human reads, and how a row renders in that column.

The cell function is what lets the two audiences disagree without either one compromising. A size is an int64 in the JSON record and "1.2 MiB" in the table; a timestamp is RFC 3339 in the record and "3 days ago" in the table. A column with no backing field at all — a computed total, a marker — is just a function that ignores most of its argument.

func Col

func Col[T any](header string, cell func(T) string) Column[T]

Col builds a Column. It exists for type inference: T is deduced from the cell function, so a call site names the row type once rather than once per column.

output.Table(w, groups,
    output.Col("Group", func(g GroupRow) string { return g.Name }),
    output.Col("Repositories", func(g GroupRow) string { return strconv.Itoa(g.Count) }),
)

type DiffData

type DiffData struct {
	// Text is the complete pretty rendering, without a trailing newline. Style
	// it freely: it is written through the wrapped stream, so escapes are
	// stripped when the destination cannot render them.
	Text string

	// Records are the machine's projection, one object per NDJSON line in the
	// order given. Their json tags are the public contract, not the text.
	Records []any
}

DiffData is a rendered diff prepared for both audiences, the same split TableData makes: the assembled text for the human, and the records behind it for the machine.

A diff is not a table — it is grouped under repository headings, its rows are ragged, and it ends in a summary line — so it cannot go through Table. It is still the *product* of the command that produced it, which is why it has a stdout method of its own rather than being narrated with Writer.Info.

Build it with the renderer that owns the vocabulary — `plan.Render` — rather than by hand. This package deliberately knows nothing about actions.

type Format

type Format string

Format names an --output value. The strings are the flag values themselves.

const (
	// FormatPretty is lipgloss-styled text for a human reader. The default.
	FormatPretty Format = "pretty"

	// FormatJSON is newline-delimited JSON: one object per line, so a consumer
	// can parse the stream as it arrives rather than waiting for the run to end.
	FormatJSON Format = "json"
)

type JSONWriter

type JSONWriter struct {
	// contains filtered or unexported fields
}

JSONWriter writes NDJSON: exactly one self-contained JSON object per line, so a consumer can parse the stream mid-run instead of waiting for a closing bracket that a killed process would never write.

Objects carry a "level" field. Tables go to stdout; progress, warnings, and errors go to stderr — so every line on stdout is a data record with the same keys, and `jq -r .group` never trips over a narration object that has no group.

func NewDefaultJSONWriter

func NewDefaultJSONWriter() *JSONWriter

NewDefaultJSONWriter creates a JSONWriter over os.Stdout and os.Stderr.

func NewJSONWriter

func NewJSONWriter(stdout, stderr io.Writer) *JSONWriter

NewJSONWriter creates a JSONWriter over the given streams. There is no colour to detect, so no environment is consulted.

func (*JSONWriter) Error

func (w *JSONWriter) Error(format string, args ...any)

Error emits an error-level object on stderr.

func (*JSONWriter) Info

func (w *JSONWriter) Info(format string, args ...any)

Info emits an info-level object on stderr.

func (*JSONWriter) Warn

func (w *JSONWriter) Warn(format string, args ...any)

Warn emits a warn-level object on stderr.

func (*JSONWriter) WriteDiff

func (w *JSONWriter) WriteDiff(d DiffData)

WriteDiff emits one object per record on stdout — one action per line, then the summary. Not a single document: a killed run leaves everything written so far still parseable, which is the whole reason the stream is NDJSON.

The assembled text is ignored. It is the human's rendering of the same records, and splicing it into the data stream is what puts a line on stdout that `jq` cannot type.

func (*JSONWriter) WriteErr

func (w *JSONWriter) WriteErr(err error)

WriteErr emits an error-level object on stderr, with an "error_kind" field when err wraps a known sentinel. The kind strings are the stable half of the contract — the message is free to be reworded, error_kind is not.

func (*JSONWriter) WriteEvent

func (w *JSONWriter) WriteEvent(record any, _ string, _ ...any)

WriteEvent emits the record as one object on stderr. The formatted text is ignored — it is the human's phrasing of the same fields, and a consumer that has to parse "resuming in 04:32" back into seconds is a consumer the fields exist to spare.

func (*JSONWriter) WriteResult

func (w *JSONWriter) WriteResult(record any, _ string, _ ...any)

WriteResult emits the record as one object on stdout. The formatted text is ignored — it is the human's phrasing of the same value, and splicing prose into the data stream is what puts a line into stdout that `jq` cannot type.

func (*JSONWriter) WriteTable

func (w *JSONWriter) WriteTable(t TableData)

WriteTable emits one object per source record on stdout — not a single array. An array could only be written once the last row was known, which is exactly the mid-run parseability NDJSON exists to preserve.

The records are marshalled as they are, so the keys and the types are the row struct's own: a count stays a number, and a heading reworded for the pretty table cannot disturb a consumer's filter. The headers and cells are ignored here — they are the human's rendering of the same rows.

type PrettyWriter

type PrettyWriter struct {
	// contains filtered or unexported fields
}

PrettyWriter writes lipgloss-styled output for a human reader.

Colour support is detected once, at construction, from the streams and the environment — see NewPrettyWriter. A run piped to a file or a CI log gets the same text with the escape sequences stripped, not a wall of \x1b[.

func NewDefaultPrettyWriter

func NewDefaultPrettyWriter() *PrettyWriter

NewDefaultPrettyWriter creates a PrettyWriter over os.Stdout and os.Stderr, detecting colour support from the process environment.

func NewPrettyWriter

func NewPrettyWriter(stdout, stderr io.Writer, environ []string) *PrettyWriter

NewPrettyWriter creates a PrettyWriter over the given streams.

Each stream is wrapped in a colorprofile.Writer, which downsamples or strips colour based on what that stream can actually render: truecolor, 256-colour, 16-colour, or none at all when it is not a terminal. NO_COLOR, CLICOLOR, and CLICOLOR_FORCE are honoured.

Pass nil for environ to use the process environment. Tests pass an explicit slice — []string{} for a plain, golden-comparable rendering — so the output does not depend on whoever is running them.

func (*PrettyWriter) Error

func (w *PrettyWriter) Error(format string, args ...any)

Error reports a failure on stderr.

func (*PrettyWriter) Info

func (w *PrettyWriter) Info(format string, args ...any)

Info reports normal progress on stderr.

func (*PrettyWriter) Warn

func (w *PrettyWriter) Warn(format string, args ...any)

Warn reports a recoverable problem on stderr.

func (*PrettyWriter) WriteDiff

func (w *PrettyWriter) WriteDiff(d DiffData)

WriteDiff writes the assembled diff text on stdout. The records are ignored: a human reads the rendering, not the objects behind it.

func (*PrettyWriter) WriteErr

func (w *PrettyWriter) WriteErr(err error)

WriteErr renders err as an error-level message. The sentinel kind is not shown: it exists for machines, and the message already reads well.

func (*PrettyWriter) WriteEvent

func (w *PrettyWriter) WriteEvent(_ any, format string, args ...any)

WriteEvent writes the formatted text on stderr, at warning level. The record is ignored: a human reads the sentence, not the field names behind it.

func (*PrettyWriter) WriteResult

func (w *PrettyWriter) WriteResult(_ any, format string, args ...any)

WriteResult writes the formatted text on stdout. The record is ignored: a human reads the sentence, not the field names behind it.

func (*PrettyWriter) WriteTable

func (w *PrettyWriter) WriteTable(t TableData)

WriteTable renders a bordered, column-aligned table on stdout. The records are ignored: a human reads the cells.

type TableData

type TableData struct {
	// Headers are the column headings, in order.
	Headers []string

	// Cells are the rendered rows, each with one entry per header.
	Cells [][]string

	// Records are the source rows, one per entry in Cells, marshalled as-is by
	// [JSONWriter.WriteTable].
	Records []any
}

TableData is a table prepared for both audiences: display strings for the human, and the source values themselves for the machine.

Build it with Table rather than by hand. Going through the constructor is what guarantees every row has exactly one cell per header — the alignment a [][]string could silently get wrong.

type Writer

type Writer interface {
	// Info reports normal progress on stderr. Progress is narration, not the
	// result the command exists to produce: it must not land in the file when a
	// user redirects stdout. If a command needs a result line that is not a
	// table, that is a new method, not this one.
	Info(format string, args ...any)

	// Warn reports a recoverable problem on stderr — a skipped repository, say.
	Warn(format string, args ...any)

	// Error reports a failure on stderr.
	Error(format string, args ...any)

	// WriteErr renders err as an error-level message on stderr. JSON output
	// carries an "error_kind" field when err wraps a known labelsync sentinel.
	WriteErr(err error)

	// WriteEvent reports a structured diagnostic on stderr: pretty output writes
	// the formatted text, JSON output marshals record and ignores the text.
	//
	// It is [Writer.Warn] for the messages a machine has to act on rather than
	// read — today, the rate-limit countdown, where a consumer needs the number of
	// seconds left and the resume time as fields rather than as a sentence it
	// would have to parse back out.
	//
	// stderr, not stdout, and deliberately: this is the story of making the
	// product, not the product. record must carry its own "level" so the stderr
	// stream stays one shape.
	WriteEvent(record any, format string, args ...any)

	// WriteTable renders a prepared table. Pretty output aligns the cells into a
	// bordered table; JSON output marshals one source record per line.
	//
	// Call [Table] rather than this: the generic constructor is what keeps the
	// cells aligned with the headers and pairs each rendered row with the record
	// it came from.
	WriteTable(t TableData)

	// WriteDiff renders a prepared diff on stdout. Pretty output writes the
	// assembled text; JSON output emits one object per record and ignores the
	// text entirely, so the stdout stream stays one typed object per line.
	//
	// This is the third product-level method, and it exists because a diff is
	// neither a table nor a single value: it is grouped, ragged, and ends in a
	// summary. Call `plan.Render` rather than this — the vocabulary of actions
	// belongs to the planner, not to output.
	WriteDiff(d DiffData)

	// WriteResult renders a single-line result on stdout: the product of a
	// command whose answer is not a table. Pretty output writes the formatted
	// text; JSON output marshals record and ignores the text entirely, so the
	// stdout stream stays one typed object per line.
	//
	// This is not a general-purpose print. A command reaches for it only when its
	// whole answer is one value — `labelsync version` — and a user piping stdout
	// would expect exactly that value in the file. Anything narrating the work is
	// [Writer.Info], on stderr.
	WriteResult(record any, format string, args ...any)
}

Writer is the interface every user-facing message passes through.

Writer.WriteTable and Writer.WriteResult are the only methods on stdout, and that asymmetry is the point: stdout is the product, and everything else — progress, warnings, failures — is the story of making it. It is what keeps `labelsync groups --output=json | jq` working when a repository turns out to be inaccessible halfway through, and it means a JSON run puts nothing on stdout that a consumer cannot type.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL