gitFlow/internal/presenter/presenter.go
dimitar c9ff44298c feat: phase 3 — presentation layer (table, JSON, compact)
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.
2026-08-02 08:41:18 +02:00

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)
}