Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions cmd/optiqor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/optiqor/optiqor-cli/internal/render/style"
roastpkg "github.com/optiqor/optiqor-cli/internal/roast"
"github.com/optiqor/optiqor-cli/internal/share"
"github.com/optiqor/optiqor-cli/internal/watch"
"github.com/optiqor/optiqor-cli/pkg/htmlrender"
"github.com/optiqor/optiqor-cli/pkg/rules"
)
Expand Down Expand Up @@ -553,13 +554,43 @@ func newAuditCmd() *cobra.Command {
}

func newWatchCmd() *cobra.Command {
return &cobra.Command{
var jsonOut bool
cmd := &cobra.Command{
Use: "watch [chart]",
Short: "Watch a chart and re-analyze on change",
RunE: func(cmd *cobra.Command, _ []string) error {
return notYetImplemented(cmd)
Long: `Watches a Helm chart directory or values file and re-runs analysis
whenever a YAML file changes. Clears the screen between runs when
connected to a TTY. Press Ctrl-C to exit cleanly (exit code 0).`,
Example: ` optiqor watch ./my-chart
optiqor watch ./values.yaml --json`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := "."
if len(args) == 1 {
path = args[0]
}

// Resolve color and TTY from the same helpers the rest of
// the CLI uses so --no-color and NO_COLOR are honoured.
useColor := colorPolicyFrom(cmd.Context())
out := cmd.OutOrStdout()

isTTY := false
if f, ok := out.(*os.File); ok {
isTTY = style.IsTTY(f)
}

return watch.Run(path, watch.Options{
JSON: jsonOut,
Color: useColor,
Width: terminalWidth(),
Out: out,
IsTTY: isTTY,
})
},
}
cmd.Flags().BoolVar(&jsonOut, "json", false, "stream newline-delimited JSON events instead of reprinting the full report")
return cmd
}

func newCompareCmd() *cobra.Command {
Expand All @@ -583,7 +614,3 @@ func newCompareCmd() *cobra.Command {
cmd.Flags().BoolVar(&jsonOut, "json", false, "emit machine-readable JSON")
return cmd
}

func notYetImplemented(cmd *cobra.Command) error {
return fmt.Errorf("`optiqor %s` is not yet implemented (see https://optiqor.dev/roadmap)", cmd.Name())
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.24

require (
github.com/charmbracelet/lipgloss v1.1.0
github.com/fsnotify/fsnotify v1.10.1
github.com/mattn/go-isatty v0.0.21
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.8.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
Expand Down
280 changes: 280 additions & 0 deletions internal/watch/watch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
// Package watch implements the `optiqor watch` command: re-analyzes a
// Helm chart directory or values file whenever a YAML file changes.
//
// Hard rules (from CLAUDE.md):
// - No telemetry. No network calls.
// - Ctrl-C exits with code 0 (clean shutdown).
// - Windows is explicitly out of scope per CLAUDE.md; fsnotify still
// compiles on Windows but is not a test target.
package watch

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"

"github.com/fsnotify/fsnotify"

"github.com/optiqor/optiqor-cli/internal/analyze"
"github.com/optiqor/optiqor-cli/internal/render"
"github.com/optiqor/optiqor-cli/internal/render/style"
)

// debounceDelay is the quiet period after the last filesystem event
// before we re-run analysis. 200 ms absorbs editor "save storms"
// (vim writes a swap file then the real file; most editors do two or
// three writes per save).
const debounceDelay = 200 * time.Millisecond

// clearScreen is the ANSI escape to move the cursor to the home
// position and erase the display. Only emitted when stdout is a TTY.
const clearScreen = "\033[H\033[2J"

// Options controls a single watch session.
type Options struct {
// JSON streams newline-delimited JSON events instead of clearing
// and reprinting the full text report.
JSON bool

// Color controls ANSI output in text mode. Ignored in JSON mode.
Color bool

// Width is the terminal width for text rendering. 0 → 80.
Width int

// Out is where rendered output goes. nil → os.Stdout.
Out io.Writer

// IsTTY reports whether Out is a real terminal (used for screen
// clear). Exported as a field so tests can stub it without
// needing a real TTY file descriptor.
IsTTY bool

// ctx is an optional context for testing. When nil, Run creates
// its own context from SIGINT/SIGTERM. Tests inject a cancellable
// context so they don't need to send OS signals.
// The field is unexported; callers use WithContext to set it.
ctx context.Context //nolint:containedctx // intentional: test-only injection, not passed via function arg
}

// WithContext returns a copy of opts with the given context set.
// Used by tests to inject a cancellable context instead of relying
// on OS signals, which are not reliably sendable across goroutines
// in the test runner on all platforms.
// FIX: ctx is first parameter per Go convention (revive: context-as-argument).
func WithContext(ctx context.Context, opts Options) Options {
opts.ctx = ctx
return opts
}

// safeWriter wraps an io.Writer with a mutex so concurrent goroutines
// (the Run loop and the test reader) don't race on the same buffer.
type safeWriter struct {
mu sync.Mutex
w io.Writer
}

func (s *safeWriter) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.w.Write(p)
}

// jsonEvent is the schema for --json streaming output.
type jsonEvent struct {
Event string `json:"event"` // "start" | "update" | "error"
Timestamp string `json:"timestamp"` // RFC3339
Path string `json:"path"`
Findings []any `json:"findings,omitempty"`
Error string `json:"error,omitempty"`
Report *render.Report `json:"report,omitempty"`
}

// Run starts the watch loop for path and blocks until the process
// receives SIGINT or SIGTERM (or opts.ctx is cancelled in tests).
// Returns nil on clean shutdown; non-nil only on setup failure.
func Run(path string, opts Options) error {
abs, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("watch: resolve path: %w", err)
}

info, err := os.Stat(abs)
if err != nil {
return fmt.Errorf("watch: stat %s: %w", abs, err)
}

watchDir := abs
if !info.IsDir() {
watchDir = filepath.Dir(abs)
}

w, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("watch: create watcher: %w", err)
}
defer func() { _ = w.Close() }()

if err := w.Add(watchDir); err != nil {
return fmt.Errorf("watch: add %s: %w", watchDir, err)
}

// Wrap the output writer in a mutex-protected wrapper so the
// background Run goroutine and the test reader goroutine don't
// race on the same bytes.Buffer.
out := opts.Out
if out == nil {
out = os.Stdout
}
safe := &safeWriter{w: out}

// Use injected context (tests) or create one from OS signals (production).
ctx := opts.ctx
if ctx == nil {
var cancel context.CancelFunc
ctx, cancel = signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
}

// Run once immediately on start.
runOnce(abs, opts, safe)

// Debounce timer: stop immediately so it doesn't fire on its own.
timer := time.NewTimer(0)
timer.Stop()
select {
case <-timer.C:
default:
}

for {
select {
case <-ctx.Done():
return nil

case event, ok := <-w.Events:
if !ok {
return nil
}
if !isRelevant(event) {
continue
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(debounceDelay)

case err, ok := <-w.Errors:
if !ok {
return nil
}
emitError(safe, opts, abs, err)

case <-timer.C:
runOnce(abs, opts, safe)
}
}
}

// isRelevant returns true for filesystem events we care about: writes,
// creates, and removes of YAML files.
func isRelevant(e fsnotify.Event) bool {
if e.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Remove) == 0 {
return false
}
name := strings.ToLower(e.Name)
return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml")
}

// runOnce runs analysis on path and prints the result to out.
// eventKind is "start" or "update" (JSON mode only).
func runOnce(path string, opts Options, out io.Writer) {
rep, err := analyze.RunPath(path)
if err != nil {
emitError(out, opts, path, err)
return
}

if opts.JSON {
emitJSON(out, "start", path, rep)
return
}

if opts.IsTTY {
_, _ = fmt.Fprint(out, clearScreen)
}

rOpts := render.Options{
Color: opts.Color,
Width: opts.Width,
}
_ = render.Text(out, rep, rOpts)
}

// emitJSON writes a single newline-delimited JSON event to out.
func emitJSON(out io.Writer, eventKind, path string, rep render.Report) {
findings := make([]any, len(rep.Findings))
for i, f := range rep.Findings {
findings[i] = f
}
evt := jsonEvent{
Event: eventKind,
Timestamp: time.Now().UTC().Format(time.RFC3339),
Path: path,
Findings: findings,
Report: &rep,
}
b, err := json.Marshal(evt)
if err != nil {
return
}
_, _ = fmt.Fprintf(out, "%s\n", b)
}

// emitError writes an error in JSON or plain text depending on opts.
func emitError(out io.Writer, opts Options, path string, err error) {
if opts.JSON {
evt := jsonEvent{
Event: "error",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Path: path,
Error: err.Error(),
}
b, _ := json.Marshal(evt)
_, _ = fmt.Fprintf(out, "%s\n", b)
return
}
t := style.NewTheme(opts.Color)
_, _ = fmt.Fprintln(out, t.SevHigh.Render(" ERROR ")+" "+err.Error())
}

// safeBuffer is a bytes.Buffer protected by a mutex, used in tests
// so the Run goroutine and the test goroutine don't race.
type safeBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}

func (sb *safeBuffer) Write(p []byte) (int, error) {
sb.mu.Lock()
defer sb.mu.Unlock()
return sb.buf.Write(p)
}

func (sb *safeBuffer) String() string {
sb.mu.Lock()
defer sb.mu.Unlock()
return sb.buf.String()
}
Loading
Loading