Replace the interim renderers with a dedicated presenter package that
formats scan results in three ways.
Presenter (internal/presenter):
- Formatter interface with Present()/For() dispatch on format name:
table, json, compact
- TableFormatter: aligned tabwriter table with REPOSITORY / BRANCH /
STATUS / AHEAD-BEHIND / CHANGES / STASH columns, per-repo change
summaries like "2M 1U", error details inline, and a colored summary
line ("N repos | N clean | N need attention | N errors")
- JSONFormatter: indented document with scanned_at, parent_dir, repos,
and an aggregate summary for scripting; statuses render as string
labels ("modified") instead of raw integers
- CompactFormatter: one line per repo with color-coded status symbols
(✓ ✗ ↑ ↓ ⇄ ◉ ▢ !) plus branch, ahead/behind, and change counts
- Color handling: auto/always/never modes, TTY detection, and the
NO_COLOR convention (explicit --color=always still wins); paths have
$HOME collapsed to "~" in table and compact views
Domain model (pkg/status):
- JSON tags on RepoInfo/ScanResult/Summary for clean field names
- RepoStatus now marshals to its string label and unmarshals from both
string labels and numeric values, so JSON output round-trips
Configuration (internal/config):
- New --color flag (auto/always/never) validated in Load and included in
the config dump; keyed as "color" in viper
CLI (cmd/gitflow):
- scan now routes through presenter.Present with the resolved color mode;
the interim renderers are removed
Testing:
- Table content (columns, change summaries, error text, summary line) and
absence of escape codes with ColorNever
- ColorAlways emits ANSI codes even under NO_COLOR; auto stays clean on
non-terminal writers
- JSON decodes back into the domain types (string statuses round-trip)
- Compact symbols and counts; unknown formats rejected
- RepoStatus JSON round trip covers every status
Verified: go build, go vet, go test -race, gofmt clean; manual smoke of
table / compact / forced-color / JSON output against a scratch directory.
153 lines
3.4 KiB
Go
153 lines
3.4 KiB
Go
// Package presenter renders scan results as human- or machine-readable
|
|
// output: a colorized terminal table, indented JSON, or a compact
|
|
// one-liner format.
|
|
package presenter
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
|
)
|
|
|
|
// ColorMode controls ANSI color rendering.
|
|
type ColorMode int
|
|
|
|
const (
|
|
ColorAuto ColorMode = iota // color only when writing to a terminal
|
|
ColorAlways // always emit ANSI color
|
|
ColorNever // never emit ANSI color
|
|
)
|
|
|
|
// ParseColorMode converts a CLI string to a ColorMode; anything unknown
|
|
// falls back to auto.
|
|
func ParseColorMode(s string) ColorMode {
|
|
switch s {
|
|
case "always":
|
|
return ColorAlways
|
|
case "never":
|
|
return ColorNever
|
|
default:
|
|
return ColorAuto
|
|
}
|
|
}
|
|
|
|
// Options control rendering behaviour.
|
|
type Options struct {
|
|
Color ColorMode
|
|
}
|
|
|
|
// Formatter renders a ScanResult to a writer.
|
|
type Formatter interface {
|
|
Format(w io.Writer, result status.ScanResult) error
|
|
}
|
|
|
|
// For returns the formatter matching a format name ("table", "json", or
|
|
// "compact").
|
|
func For(format string, opts Options) (Formatter, error) {
|
|
switch format {
|
|
case "table":
|
|
return &TableFormatter{opts: opts}, nil
|
|
case "json":
|
|
return &JSONFormatter{}, nil
|
|
case "compact":
|
|
return &CompactFormatter{opts: opts}, nil
|
|
default:
|
|
return nil, fmt.Errorf("presenter: unsupported format %q (want table, json, or compact)", format)
|
|
}
|
|
}
|
|
|
|
// Present renders result to w in the given format.
|
|
func Present(w io.Writer, format string, result status.ScanResult, opts Options) error {
|
|
formatter, err := For(format, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return formatter.Format(w, result)
|
|
}
|
|
|
|
// renderer resolves ANSI escape codes according to the color mode, the
|
|
// destination, and the NO_COLOR convention (https://no-color.org).
|
|
type renderer struct {
|
|
green string
|
|
yellow string
|
|
red string
|
|
cyan string
|
|
blue string
|
|
reset string
|
|
}
|
|
|
|
func newRenderer(mode ColorMode, w io.Writer) renderer {
|
|
useColor := false
|
|
switch mode {
|
|
case ColorAlways:
|
|
useColor = true
|
|
case ColorNever:
|
|
useColor = false
|
|
default: // ColorAuto
|
|
useColor = isTTY(w) && os.Getenv("NO_COLOR") == ""
|
|
}
|
|
if !useColor {
|
|
return renderer{}
|
|
}
|
|
return renderer{
|
|
green: "\x1b[32m",
|
|
yellow: "\x1b[33m",
|
|
red: "\x1b[31m",
|
|
cyan: "\x1b[36m",
|
|
blue: "\x1b[34m",
|
|
reset: "\x1b[0m",
|
|
}
|
|
}
|
|
|
|
// paint wraps text in the color for the given status.
|
|
func (r renderer) paint(s status.RepoStatus, text string) string {
|
|
var code string
|
|
switch s {
|
|
case status.StatusClean:
|
|
code = r.green
|
|
case status.StatusModified:
|
|
code = r.yellow
|
|
case status.StatusDiverged, status.StatusError:
|
|
code = r.red
|
|
case status.StatusAhead:
|
|
code = r.blue
|
|
case status.StatusBehind:
|
|
code = r.yellow
|
|
case status.StatusDetached, status.StatusBare:
|
|
code = r.cyan
|
|
}
|
|
if code == "" {
|
|
return text
|
|
}
|
|
return code + text + r.reset
|
|
}
|
|
|
|
func isTTY(w io.Writer) bool {
|
|
f, ok := w.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.Mode()&os.ModeCharDevice != 0
|
|
}
|
|
|
|
// shortPath renders a path with the home directory collapsed to "~".
|
|
func shortPath(p string) string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return p
|
|
}
|
|
rel, err := filepath.Rel(home, p)
|
|
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
|
|
return p
|
|
}
|
|
return filepath.Join("~", rel)
|
|
}
|